1use std::fmt;
20use std::time::Duration;
21
22use mail4agent_api::{
23 AckRequest, AckResponse, Address, Directory, InboxPage, InboxRequest, MailError, Message,
24 MessageGetRequest, ParticipantId, RoomId, SendRequest, SendResponse, SessionCard,
25 SessionDeclared, UnreadCount, UnreadCountRequest,
26};
27use reqwest::{Client, StatusCode, Url};
28use serde::{Deserialize, Serialize};
29use thiserror::Error;
30
31pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:18301";
33
34#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
36pub struct HealthReport {
37 pub ok: bool,
38 pub service: String,
39 pub version: Option<String>,
40 pub started_at: String,
41 pub uptime_secs: u64,
42 #[serde(default)]
43 pub dependencies: Vec<serde_json::Value>,
44 #[serde(default)]
45 pub background_tasks: Vec<String>,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
50pub struct WhoAmIResponse {
51 pub address: Address,
52 pub label: Option<String>,
53 pub rooms: Vec<RoomId>,
54 pub card: Option<SessionCard>,
55}
56
57#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
59pub struct StatusResponse {
60 pub card: SessionCard,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
65pub struct RegisterParticipantRequest {
66 pub id: ParticipantId,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub label: Option<String>,
69 #[serde(default)]
70 pub may_send: bool,
71 #[serde(default)]
72 pub may_read: bool,
73 #[serde(default)]
74 pub operator: bool,
75}
76
77#[derive(Clone, Eq, PartialEq, Serialize, Deserialize)]
79pub struct SecretResponse {
80 pub id: ParticipantId,
81 pub secret: String,
82}
83
84impl fmt::Display for SecretResponse {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 write!(f, "SecretResponse {{ id: {}, secret: [redacted] }}", self.id)
88 }
89}
90
91impl fmt::Debug for SecretResponse {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 f.debug_struct("SecretResponse")
94 .field("id", &self.id)
95 .field("secret", &"[redacted]")
96 .finish()
97 }
98}
99
100#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
102pub struct ParticipantIdRequest {
103 pub id: ParticipantId,
104}
105
106#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
108pub struct RoomIdRequest {
109 pub id: RoomId,
110}
111
112#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
114pub struct RoomMemberRequest {
115 pub room: RoomId,
116 pub participant: ParticipantId,
117}
118
119#[derive(Debug, Error)]
122pub enum ClientError {
123 #[error("invalid base URL: {0}")]
124 InvalidBaseUrl(String),
125 #[error("build HTTP client: {0}")]
126 Build(reqwest::Error),
127 #[error("HTTP transport: {0}")]
128 Transport(#[from] reqwest::Error),
129 #[error("mailbox refused: {0}")]
130 Mail(#[from] MailError),
131 #[error("unexpected HTTP {status}: {body}")]
132 UnexpectedStatus { status: StatusCode, body: String },
133 #[error("decode response: {0}")]
134 Decode(String),
135 #[error("base URL must be loopback HTTP for S2/local smoke (got {0})")]
136 NonLoopbackBase(String),
137}
138
139#[derive(Clone)]
145pub struct MailClient {
146 http: Client,
147 base: Url,
148 bearer: String,
149}
150
151impl fmt::Debug for MailClient {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 f.debug_struct("MailClient")
154 .field("base", &self.base)
155 .field("bearer", &"[redacted]")
156 .finish()
157 }
158}
159
160impl MailClient {
161 pub fn new(base_url: impl AsRef<str>, bearer: impl Into<String>) -> Result<Self, ClientError> {
166 MailClientBuilder::new(base_url)?.bearer(bearer).build()
167 }
168
169 pub fn new_unchecked(base_url: impl AsRef<str>, bearer: impl Into<String>) -> Result<Self, ClientError> {
172 MailClientBuilder::new(base_url)?
173 .bearer(bearer)
174 .allow_non_loopback()
175 .build()
176 }
177
178 pub fn builder(base_url: impl AsRef<str>) -> Result<MailClientBuilder, ClientError> {
179 MailClientBuilder::new(base_url)
180 }
181
182 pub fn base_url(&self) -> &Url {
183 &self.base
184 }
185
186 pub async fn health_at(base_url: impl AsRef<str>) -> Result<HealthReport, ClientError> {
189 let base = parse_base(base_url.as_ref(), true)?;
190 let http = Client::builder()
191 .timeout(Duration::from_secs(5))
192 .build()
193 .map_err(ClientError::Build)?;
194 get_json(&http, base.join("health").map_err(|e| ClientError::InvalidBaseUrl(e.to_string()))?).await
195 }
196
197 pub async fn health(&self) -> Result<HealthReport, ClientError> {
199 get_json(
200 &self.http,
201 self.base
202 .join("health")
203 .map_err(|e| ClientError::InvalidBaseUrl(e.to_string()))?,
204 )
205 .await
206 }
207
208 pub async fn send(&self, request: SendRequest) -> Result<SendResponse, ClientError> {
209 self.post_json("mail/send", &request).await
210 }
211
212 pub async fn inbox(&self, request: InboxRequest) -> Result<InboxPage, ClientError> {
213 self.post_json("mail/inbox", &request).await
214 }
215
216 pub async fn ack(&self, request: AckRequest) -> Result<AckResponse, ClientError> {
217 self.post_json("mail/ack", &request).await
218 }
219
220 pub async fn get_message(&self, request: MessageGetRequest) -> Result<Message, ClientError> {
221 self.post_json("mail/get", &request).await
222 }
223
224 pub async fn unread(&self, request: UnreadCountRequest) -> Result<UnreadCount, ClientError> {
225 self.post_json("mail/unread", &request).await
226 }
227
228 pub async fn directory(&self) -> Result<Directory, ClientError> {
230 self.post_empty("mail/directory").await
231 }
232
233 pub async fn whoami(&self) -> Result<WhoAmIResponse, ClientError> {
235 self.post_empty("mail/whoami").await
236 }
237
238 pub async fn status(&self, request: SessionDeclared) -> Result<StatusResponse, ClientError> {
239 self.post_json("mail/status", &request).await
240 }
241
242 pub async fn admin_register_participant(
244 &self,
245 request: RegisterParticipantRequest,
246 ) -> Result<SecretResponse, ClientError> {
247 self.post_json("admin/participant", &request).await
248 }
249
250 pub async fn admin_rotate_participant(&self, id: ParticipantId) -> Result<SecretResponse, ClientError> {
251 self.post_json("admin/participant/rotate", &ParticipantIdRequest { id })
252 .await
253 }
254
255 pub async fn admin_remove_participant(&self, id: ParticipantId) -> Result<(), ClientError> {
256 let _: serde_json::Value = self
257 .post_json("admin/participant/remove", &ParticipantIdRequest { id })
258 .await?;
259 Ok(())
260 }
261
262 pub async fn admin_create_room(&self, id: RoomId) -> Result<(), ClientError> {
263 let _: serde_json::Value = self.post_json("admin/room", &RoomIdRequest { id }).await?;
264 Ok(())
265 }
266
267 pub async fn admin_add_room_member(&self, room: RoomId, participant: ParticipantId) -> Result<(), ClientError> {
268 let _: serde_json::Value = self
269 .post_json("admin/room/member/add", &RoomMemberRequest { room, participant })
270 .await?;
271 Ok(())
272 }
273
274 pub async fn admin_remove_room_member(
275 &self,
276 room: RoomId,
277 participant: ParticipantId,
278 ) -> Result<(), ClientError> {
279 let _: serde_json::Value = self
280 .post_json(
281 "admin/room/member/remove",
282 &RoomMemberRequest { room, participant },
283 )
284 .await?;
285 Ok(())
286 }
287
288 pub async fn send_direct(
290 &self,
291 to: ParticipantId,
292 subject: impl Into<String>,
293 body: impl Into<String>,
294 ) -> Result<SendResponse, ClientError> {
295 self.send(SendRequest {
296 to: Address::Direct { participant: to },
297 subject: subject.into(),
298 body: body.into(),
299 reply_to: None,
300 correlation: None,
301 refs: Vec::new(),
302 idempotency_key: None,
303 })
304 .await
305 }
306
307 pub async fn list_inbox(&self) -> Result<InboxPage, ClientError> {
309 self.inbox(InboxRequest {
310 since_unix_ms: None,
311 limit: mail4agent_api::INBOX_LIMIT_DEFAULT,
312 wait_secs: None,
313 })
314 .await
315 }
316
317 async fn post_json<B: Serialize, R: for<'de> Deserialize<'de>>(
318 &self,
319 path: &str,
320 body: &B,
321 ) -> Result<R, ClientError> {
322 let url = self
323 .base
324 .join(path)
325 .map_err(|e| ClientError::InvalidBaseUrl(e.to_string()))?;
326 let response = self
327 .http
328 .post(url)
329 .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", self.bearer))
330 .json(body)
331 .send()
332 .await?;
333 decode_response(response).await
334 }
335
336 async fn post_empty<R: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<R, ClientError> {
337 let url = self
338 .base
339 .join(path)
340 .map_err(|e| ClientError::InvalidBaseUrl(e.to_string()))?;
341 let response = self
342 .http
343 .post(url)
344 .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", self.bearer))
345 .header(reqwest::header::CONTENT_TYPE, "application/json")
348 .body("{}")
349 .send()
350 .await?;
351 decode_response(response).await
352 }
353}
354
355pub struct MailClientBuilder {
357 base: Url,
358 bearer: Option<String>,
359 timeout: Duration,
360 require_loopback: bool,
361}
362
363impl MailClientBuilder {
364 pub fn new(base_url: impl AsRef<str>) -> Result<Self, ClientError> {
365 let base = parse_base(base_url.as_ref(), false)?;
366 Ok(Self {
367 base,
368 bearer: None,
369 timeout: Duration::from_secs(30),
370 require_loopback: true,
371 })
372 }
373
374 pub fn bearer(mut self, bearer: impl Into<String>) -> Self {
375 self.bearer = Some(bearer.into());
376 self
377 }
378
379 pub fn timeout(mut self, timeout: Duration) -> Self {
380 self.timeout = timeout;
381 self
382 }
383
384 pub fn allow_non_loopback(mut self) -> Self {
385 self.require_loopback = false;
386 self
387 }
388
389 pub fn build(self) -> Result<MailClient, ClientError> {
390 if self.require_loopback {
391 enforce_loopback(&self.base)?;
392 }
393 let bearer = self
394 .bearer
395 .filter(|b| !b.is_empty())
396 .ok_or_else(|| ClientError::InvalidBaseUrl("bearer must be non-empty".into()))?;
397 let http = Client::builder()
398 .timeout(self.timeout)
399 .build()
400 .map_err(ClientError::Build)?;
401 Ok(MailClient {
402 http,
403 base: self.base,
404 bearer,
405 })
406 }
407}
408
409fn parse_base(raw: &str, enforce: bool) -> Result<Url, ClientError> {
410 let mut url = Url::parse(raw).map_err(|e| ClientError::InvalidBaseUrl(e.to_string()))?;
411 if url.scheme() != "http" && url.scheme() != "https" {
412 return Err(ClientError::InvalidBaseUrl(format!(
413 "scheme must be http(s), got {}",
414 url.scheme()
415 )));
416 }
417 {
420 let mut path = url.path().to_string();
421 if path.is_empty() || path == "/" {
422 path = "/".to_string();
423 } else if !path.ends_with('/') {
424 path.push('/');
425 }
426 url.set_path(&path);
427 }
428 if enforce {
429 enforce_loopback(&url)?;
430 }
431 Ok(url)
432}
433
434fn enforce_loopback(url: &Url) -> Result<(), ClientError> {
435 let host = url.host_str().unwrap_or("");
436 let loopback = host == "localhost"
437 || host == "127.0.0.1"
438 || host == "::1"
439 || host == "[::1]";
440 if loopback {
441 Ok(())
442 } else {
443 Err(ClientError::NonLoopbackBase(url.to_string()))
444 }
445}
446
447async fn get_json<R: for<'de> Deserialize<'de>>(http: &Client, url: Url) -> Result<R, ClientError> {
448 let response = http.get(url).send().await?;
449 decode_response(response).await
450}
451
452async fn decode_response<R: for<'de> Deserialize<'de>>(
453 response: reqwest::Response,
454) -> Result<R, ClientError> {
455 let status = response.status();
456 let bytes = response.bytes().await?;
457 if status.is_success() {
458 return serde_json::from_slice(&bytes).map_err(|e| ClientError::Decode(e.to_string()));
459 }
460 if let Ok(err) = serde_json::from_slice::<MailError>(&bytes) {
462 return Err(ClientError::Mail(err));
463 }
464 let body = String::from_utf8_lossy(&bytes).into_owned();
465 Err(ClientError::UnexpectedStatus { status, body })
466}
467
468pub use mail4agent_api::{
470 AckRequest as MailAckRequest, Address as MailAddress, Directory as MailDirectory,
471 InboxRequest as MailInboxRequest, MessageId as MailMessageId, ParticipantId as MailParticipantId,
472 RoomId as MailRoomId, SendRequest as MailSendRequest, SendResponse as MailSendResponse,
473 INBOX_LIMIT_DEFAULT,
474};
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn default_base_parses_and_is_loopback() {
482 let url = parse_base(DEFAULT_BASE_URL, true).expect("default base");
483 assert_eq!(url.as_str(), "http://127.0.0.1:18301/");
484 }
485
486 #[test]
487 fn non_loopback_is_refused_by_default() {
488 let err = parse_base("http://example.com:18301", true).expect_err("remote");
489 assert!(matches!(err, ClientError::NonLoopbackBase(_)));
490 }
491
492 #[test]
493 fn secret_response_display_redacts() {
494 let id = ParticipantId::new("alice").unwrap();
495 let s = SecretResponse {
496 id,
497 secret: "super-secret-value".into(),
498 };
499 let shown = format!("{s}");
500 assert!(!shown.contains("super-secret"));
501 assert!(shown.contains("[redacted]"));
502 }
503
504 #[test]
505 fn join_mail_send_path() {
506 let base = parse_base(DEFAULT_BASE_URL, true).unwrap();
507 let joined = base.join("mail/send").unwrap();
508 assert_eq!(joined.as_str(), "http://127.0.0.1:18301/mail/send");
509 }
510}