Skip to content

Commit 72c2237

Browse files
keypair34claude
andcommitted
feat(email): add transactional email SDK (Rust crate + Ruby gem)
Reference SDK for the smbCloud transactional email API. crates/smbcloud-email-sdk (Rust, mirrors smbcloud-gresiq-sdk): - EmailClient: send / get_message / list_messages over reqwest, with Authorization: Bearer <smb_mail_ key> - SendEmail builder (html/text, cc/bcc/reply_to, attachments, headers, tags, idempotency_key); EmailMessage/EmailEvent; EmailStatus via serde_repr to match the server enum - EmailCredentials + base_url(Environment); EmailError - doctests + fmt + clippy clean sdk/gems/email (Ruby native gem, mirrors sdk/gems/auth): - Magnus extension wrapping smbcloud-email-sdk (__send/__get_message/ __list_messages), build.rs macOS install-name fix - SmbCloud::Email::Client#send/#get_message/#list_messages, error normalization, version/gemspec/Rakefile/sig/rubocop - builds against the in-repo crate via [patch.crates-io]; release tooling publishes smbcloud-email-sdk first, after which the version dep resolves from crates.io Verified: crate builds + doctests pass; gem compiles under Ruby 3.4.2 and the full Ruby -> Magnus -> Rust -> HTTP path works end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a95e6e7 commit 72c2237

30 files changed

Lines changed: 3388 additions & 0 deletions

Cargo.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ serde_json = "1.0.82"
2828
serde_repr = "0.1"
2929
smbcloud-auth = { version = "0.4.7", path = "crates/smbcloud-auth" }
3030
smbcloud-auth-sdk = { version = "0.4.7", path = "crates/smbcloud-auth-sdk" }
31+
smbcloud-email-sdk = { version = "0.4.7", path = "crates/smbcloud-email-sdk" }
3132
smbcloud-gresiq-sdk = { version = "0.4.7", path = "crates/smbcloud-gresiq-sdk" }
3233
smbcloud-mail = { version = "0.4.7", path = "crates/smbcloud-mail" }
3334
smbcloud-model = { version = "0.4.7", path = "crates/smbcloud-model" }
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[package]
2+
name = "smbcloud-email-sdk"
3+
version = "0.4.7"
4+
edition = "2021"
5+
description = "Rust client for the smbCloud transactional email API — send email from a verified domain and read delivery status."
6+
license = "Apache-2.0"
7+
authors = ["Seto Elkahfi <hej@setoelkahfi.se>"]
8+
repository = "https://github.com/smbcloudXYZ/smbcloud-cli"
9+
homepage = "https://smbcloud.xyz"
10+
documentation = "https://docs.rs/smbcloud-email-sdk"
11+
readme = "README.md"
12+
keywords = ["smbcloud", "email", "transactional", "api", "mail"]
13+
categories = ["api-bindings", "network-programming", "web-programming::http-client"]
14+
15+
[lib]
16+
path = "src/smbcloud_email_sdk.rs"
17+
18+
[dependencies]
19+
log = { workspace = true }
20+
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
21+
serde = { workspace = true, features = ["derive"] }
22+
serde_json = { workspace = true }
23+
serde_repr = { workspace = true }
24+
smbcloud-network = { workspace = true }
25+
thiserror = { workspace = true }
26+
tokio = { workspace = true, features = ["rt"] }
27+
28+
[dev-dependencies]
29+
anyhow = { workspace = true }
30+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# smbcloud-email-sdk
2+
3+
Rust client for the **smbCloud transactional email API**. Send transactional
4+
email from a verified domain and read each message's delivery status.
5+
6+
The crate handles the HTTP transport and the `Authorization: Bearer <api_key>`
7+
header. You build the message and own the content.
8+
9+
## Install
10+
11+
```toml
12+
[dependencies]
13+
smbcloud-email-sdk = "0.4"
14+
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
15+
```
16+
17+
## Authentication
18+
19+
Mint a `smb_mail_…` API key for your Mail app in the smbCloud console. Sending is
20+
scoped to that app's verified domain; reading messages needs a read-scope key.
21+
22+
## Send
23+
24+
```rust,no_run
25+
use smbcloud_email_sdk::{EmailClient, EmailCredentials, Environment, SendEmail};
26+
27+
#[tokio::main]
28+
async fn main() -> anyhow::Result<()> {
29+
let client = EmailClient::from_credentials(
30+
Environment::Production,
31+
EmailCredentials { api_key: "smb_mail_your_key" },
32+
);
33+
34+
let message = SendEmail::new("billing@example.com", ["customer@acme.com"])
35+
.subject("Your receipt")
36+
.html("<h1>Thanks!</h1>")
37+
.text("Thanks!")
38+
.idempotency_key("receipt-2026-0001");
39+
40+
let sent = client.send(&message).await?;
41+
println!("sent {} ({:?})", sent.id, sent.status);
42+
Ok(())
43+
}
44+
```
45+
46+
## Attachments, cc/bcc, headers, tags
47+
48+
```rust,no_run
49+
# use smbcloud_email_sdk::{Attachment, SendEmail};
50+
let message = SendEmail::new("billing@example.com", ["customer@acme.com"])
51+
.subject("Invoice")
52+
.html("<p>See attached.</p>")
53+
.cc(["accounts@acme.com"])
54+
.reply_to(["support@example.com"])
55+
.attachment(Attachment {
56+
filename: "invoice.pdf".into(),
57+
content_base64: "JVBERi0xLjQ...".into(),
58+
})
59+
.header("X-Entity-Ref-ID", "inv_123")
60+
.tag("category", "invoice");
61+
```
62+
63+
## Read delivery status
64+
65+
```rust,no_run
66+
# async fn run(client: smbcloud_email_sdk::EmailClient) -> anyhow::Result<()> {
67+
let message = client.get_message("eml_…").await?;
68+
println!("status: {:?}", message.status);
69+
for event in &message.events {
70+
println!(" {} at {}", event.event_type, event.occurred_at);
71+
}
72+
73+
let recent = client.list_messages(Some("bounced"), Some(20)).await?;
74+
println!("{} bounced", recent.len());
75+
# Ok(())
76+
# }
77+
```
78+
79+
## License
80+
81+
Apache-2.0
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
use smbcloud_network::environment::Environment;
2+
3+
/// API credential for the smbCloud transactional email API.
4+
///
5+
/// `api_key` is a `smb_mail_…` key minted for a Mail app in the smbCloud
6+
/// console. It is sent as `Authorization: Bearer <api_key>` and scopes sending
7+
/// to that app's verified domain.
8+
#[derive(Clone, Copy)]
9+
pub struct EmailCredentials<'a> {
10+
pub api_key: &'a str,
11+
}
12+
13+
/// Resolve the email API base URL for the given environment.
14+
///
15+
/// - **Dev** → `http://localhost:8088`
16+
/// - **Production** → `https://api.smbcloud.xyz`
17+
pub fn base_url(environment: &Environment) -> String {
18+
format!(
19+
"{}://{}",
20+
environment.api_protocol(),
21+
environment.api_host()
22+
)
23+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
use thiserror::Error;
2+
3+
#[derive(Debug, Error)]
4+
pub enum EmailError {
5+
/// The HTTP layer failed before we got a response — network down, DNS
6+
/// failure, TLS handshake, that sort of thing.
7+
#[error("request failed: {0}")]
8+
Http(#[from] reqwest::Error),
9+
10+
/// The API replied with a non-2xx status. `message` is whatever the server
11+
/// put in the response body (typically `{ "message": "..." }`), or the HTTP
12+
/// reason phrase if the body wasn't readable.
13+
#[error("email API error {status}: {message}")]
14+
Api { status: u16, message: String },
15+
}

0 commit comments

Comments
 (0)