Skip to main content

mail4agent_client/
lib.rs

1//! HTTP client for a running [`mail4agent`](https://github.com/ZENG3LD/mail4agent) daemon.
2//!
3//! Covers the public mailbox surface the daemon exposes over loopback HTTP:
4//! `GET /health` (no auth) and the bearer-gated `POST /mail/*` plus operator
5//! `POST /admin/*` routes. Wire types for mail transport come from
6//! [`mail4agent_api`]; admin / whoami / status display shapes that live only
7//! in the daemon are mirrored here so a caller never needs to depend on the
8//! binary crate.
9//!
10//! **Sender is never a field.** Every `/mail/*` call authenticates with the
11//! bearer this client was constructed with; the daemon derives `from` from
12//! that credential plus kernel session attestation on the TCP peer. See
13//! `mail4agent` README / CLAUDE.md ("The rule that defines this service").
14//!
15//! A host application consumes this crate as a dependency — the harness holds no
16//! mailbox of its own (extraction plan
17//! `mailbox-service-extraction-and-signed-session-identity-2026-09-16.md`).
18
19use 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
31/// Default loopback bind the daemon uses when `mail4agent.toml` is absent.
32pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:18301";
33
34/// Rich liveness payload from `GET /health` (unauthenticated).
35#[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/// Answers `POST /mail/whoami` (daemon `dto::WhoAmIResponse`).
49#[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/// Answers `POST /mail/status` (daemon `dto::StatusResponse`).
58#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
59pub struct StatusResponse {
60    pub card: SessionCard,
61}
62
63/// Body for `POST /admin/participant`.
64#[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/// Answers `POST /admin/participant` / `…/rotate` — secret returned once.
78#[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        // Never print the secret via Display — Debug still redacts below.
87        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/// Body for admin routes that take only a participant id.
101#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
102pub struct ParticipantIdRequest {
103    pub id: ParticipantId,
104}
105
106/// Body for `POST /admin/room`.
107#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
108pub struct RoomIdRequest {
109    pub id: RoomId,
110}
111
112/// Body for room membership add/remove.
113#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
114pub struct RoomMemberRequest {
115    pub room: RoomId,
116    pub participant: ParticipantId,
117}
118
119/// Typed failures from the HTTP client itself (transport / decode / HTTP
120/// status that is not a named [`MailError`] JSON body).
121#[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/// Async HTTP client bound to one mailbox base URL and one bearer credential.
140///
141/// Clone is cheap (shared `reqwest::Client`). The bearer is stored so every
142/// `/mail/*` and `/admin/*` call presents the same account; session identity
143/// is still resolved server-side from the TCP peer.
144#[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    /// Builds a client for `base_url` (e.g. [`DEFAULT_BASE_URL`]) authenticating
162    /// as `bearer`. Refuses a non-loopback base by default so a typo cannot
163    /// point HQ mail at a remote host during local smoke — pass
164    /// [`MailClient::builder`] when a non-loopback target is intentional.
165    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    /// Same as [`Self::new`] but skips the loopback check (tests / explicit
170    /// remote dial only). Prefer [`Self::new`] for host-local dial.
171    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    /// `GET /health` — no bearer. Convenience that builds a one-shot client
187    /// against `base_url` without storing credentials.
188    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    /// `GET /health` on this client's base URL.
198    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    /// `POST /mail/directory` — empty body.
229    pub async fn directory(&self) -> Result<Directory, ClientError> {
230        self.post_empty("mail/directory").await
231    }
232
233    /// `POST /mail/whoami` — empty body.
234    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    /// Operator: register a participant; returns the one-time secret.
243    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    /// Convenience: send a direct message to `to` with subject/body.
289    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    /// Convenience: list inbox with default limit, no wait.
308    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            // daemon extractors still expect a body for some routes; empty JSON object is safe
346            // for whoami/directory which take no ApiJson.
347            .header(reqwest::header::CONTENT_TYPE, "application/json")
348            .body("{}")
349            .send()
350            .await?;
351        decode_response(response).await
352    }
353}
354
355/// Builder for [`MailClient`].
356pub 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    // Trailing slash so `join("mail/send")` appends under the base rather than
418    // replacing a non-empty final segment.
419    {
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    // Prefer a named MailError body when the daemon returned one.
461    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
468/// Re-export common wire types so hosts can `use mail4agent_client::*`.
469pub 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}