Skip to main content

smbcloud_email_sdk/
client.rs

1use crate::client_credentials::EmailCredentials;
2use crate::error::EmailError;
3use crate::message::{EmailMessage, SendEmail};
4use 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)]
18pub struct EmailClient {
19    base_url: String,
20    api_key: String,
21    http: reqwest::Client,
22}
23
24impl 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}