1 use crate::client_credentials::EmailCredentials;
2 use crate::error::EmailError;
3 use crate::message::{EmailMessage, SendEmail};
4 use smbcloud_network::environment::Environment;
5
6 /// Talks to the smbCloud transactional email API.
7 ///
8 /// Cheap to clone — the inner `reqwest::Client` is `Arc`-backed. Build one at
9 /// startup and clone it wherever you need it.
10 ///
11 /// # Authentication
12 ///
13 /// Every request carries `Authorization: Bearer <api_key>` from the
14 /// [`EmailCredentials`]. Mint a key for your Mail app in the smbCloud console;
15 /// sending is scoped to that app's verified domain. Reading messages
16 /// (`get_message`, `list_messages`) needs a key with read scope.
17 #[derive(Debug, Clone)]
18 pub struct EmailClient {
19 base_url: String,
20 api_key: String,
21 http: reqwest::Client,
22 }
23
24 impl EmailClient {
25 /// Build a client from an environment and credentials.
26 ///
27 /// The base URL is resolved from the environment:
28 /// - `Environment::Dev` → `http://localhost:8088`
29 /// - `Environment::Production` → `https://api.smbcloud.xyz`
30 pub fn from_credentials(environment: Environment, credentials: EmailCredentials<'_>) -> Self {
31 EmailClient {
32 base_url: crate::client_credentials::base_url(&environment),
33 api_key: credentials.api_key.to_string(),
34 http: reqwest::Client::new(),
35 }
36 }
37
38 /// Send a transactional email: `POST /v1/email/messages`.
39 ///
40 /// Returns the created [`EmailMessage`] (status `Sent`). If the message
41 /// carried an `idempotency_key` that was already used, the original message
42 /// is returned and nothing is sent again.
43 pub async fn send(&self, message: &SendEmail) -> Result<EmailMessage, EmailError> {
44 let url = format!("{}/v1/email/messages", self.base_url);
45 let response = self
46 .authed(self.http.post(&url))
47 .json(message)
48 .send()
49 .await?;
50
51 if response.status().is_success() {
52 return Ok(response.json().await?);
53 }
54 Err(self.api_error(response).await)
55 }
56
57 /// Fetch one message by id with its delivery-event timeline:
58 /// `GET /v1/email/messages/:id`. Requires a read-scope key.
59 pub async fn get_message(&self, id: &str) -> Result<EmailMessage, EmailError> {
60 let url = format!("{}/v1/email/messages/{}", self.base_url, id);
61 let response = self.authed(self.http.get(&url)).send().await?;
62
63 if response.status().is_success() {
64 return Ok(response.json().await?);
65 }
66 Err(self.api_error(response).await)
67 }
68
69 /// List recent messages: `GET /v1/email/messages`. Requires a read-scope
70 /// key. `status` filters by delivery status name (e.g. `"delivered"`);
71 /// `limit` is clamped server-side to `1..=100`.
72 pub async fn list_messages(
73 &self,
74 status: Option<&str>,
75 limit: Option<u32>,
76 ) -> Result<Vec<EmailMessage>, EmailError> {
77 let url = format!("{}/v1/email/messages", self.base_url);
78
79 let mut params: Vec<(&str, String)> = Vec::new();
80 if let Some(status) = status {
81 params.push(("status", status.to_string()));
82 }
83 if let Some(limit) = limit {
84 params.push(("limit", limit.to_string()));
85 }
86
87 let response = self
88 .authed(self.http.get(&url))
89 .query(&params)
90 .send()
91 .await?;
92
93 if response.status().is_success() {
94 return Ok(response.json().await?);
95 }
96 Err(self.api_error(response).await)
97 }
98
99 fn authed(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
100 builder.header("Authorization", format!("Bearer {}", self.api_key))
101 }
102
103 async fn api_error(&self, response: reqwest::Response) -> EmailError {
104 let status = response.status().as_u16();
105 let message = response
106 .text()
107 .await
108 .unwrap_or_else(|_| "unreadable response body".to_string());
109 EmailError::Api { status, message }
110 }
111 }