Rust client library for the Lettermint email service. HTTP client-agnostic — ships with a reqwest implementation, or bring your own by implementing the Client trait.
[dependencies]
lettermint = { version = "0.1", features = ["reqwest-rustls"] }
tokio = { version = "1", features = ["rt", "macros"] }use lettermint::api::email::SendEmailRequest;
use lettermint::reqwest::LettermintClient;
use lettermint::Query;
#[tokio::main]
async fn main() {
let client = LettermintClient::new("your-api-token");
let req = SendEmailRequest::builder()
.from("[email protected]")
.to(vec!["[email protected]".into()])
.subject("Hello from Lettermint")
.text("Plain text body")
.build();
let resp = req.execute(&client).await.unwrap();
println!("Sent: {} ({})", resp.message_id, resp.status);
}use lettermint::api::email::{SendEmailRequest, Attachment};
use lettermint::reqwest::LettermintClient;
use lettermint::Query;
use std::collections::HashMap;
async fn send_full(client: &LettermintClient) {
let req = SendEmailRequest::builder()
.from("Jane <[email protected]>")
.to(vec!["[email protected]".into()])
.subject("Monthly update")
.html("<h1>Update</h1><p>Here's what happened.</p>")
.text("Here's what happened.")
.cc(vec!["[email protected]".into()])
.bcc(vec!["[email protected]".into()])
.reply_to(vec!["[email protected]".into()])
.headers(HashMap::from([
("X-Campaign".into(), "monthly-update".into()),
]))
.attachments(vec![
Attachment::new("report.pdf", "<base64-encoded-content>"),
Attachment::inline("logo.png", "<base64-encoded-logo>", "logo"),
])
.metadata(HashMap::from([
("campaign_id".into(), "2025-03".into()),
]))
.tag("newsletter")
.route("my-route")
.idempotency_key("monthly-update-2025-03")
.build();
let resp = req.execute(client).await.unwrap();
println!("{:?}", resp);
}Send up to 500 emails in a single request:
use lettermint::api::email::{SendEmailRequest, BatchSendRequest};
use lettermint::reqwest::LettermintClient;
use lettermint::Query;
async fn send_batch(client: &LettermintClient) {
let batch = BatchSendRequest::new(vec![
SendEmailRequest::builder()
.from("[email protected]")
.to(vec!["[email protected]".into()])
.subject("Hello Alice")
.text("Hi Alice!")
.build(),
SendEmailRequest::builder()
.from("[email protected]")
.to(vec!["[email protected]".into()])
.subject("Hello Bob")
.text("Hi Bob!")
.build(),
])
.expect("batch must be 1-500 emails");
let responses = batch.execute(client).await.unwrap();
for resp in responses {
println!("Sent: {} ({})", resp.message_id, resp.status);
}
}Check API connectivity and validate credentials:
use lettermint::api::ping::PingRequest;
use lettermint::reqwest::LettermintClient;
use lettermint::Query;
async fn ping(client: &LettermintClient) {
let resp = PingRequest.execute(client).await.unwrap();
println!("API says: {}", resp.message);
}use lettermint::webhook::Webhook;
let wh = Webhook::new("whsec_your_webhook_secret").unwrap();
// Simple verification — returns parsed JSON payload
let payload = wh.verify(raw_body, signature_header).unwrap();
println!("Verified event: {}", payload);
// Full header verification — returns WebhookEvent with metadata
let event = wh.verify_headers(
signature_header,
delivery_header, // X-Lettermint-Delivery
event_header, // X-Lettermint-Event
attempt_header, // X-Lettermint-Attempt
raw_body,
).unwrap();
println!("Event: {:?}, attempt: {:?}", event.event, event.attempt);use lettermint::{Query, QueryError};
match req.execute(&client).await {
Ok(resp) => println!("Sent: {}", resp.message_id),
Err(QueryError::Validation { errors, message, .. }) => {
eprintln!("Validation failed: {message:?}, fields: {errors:?}");
}
Err(QueryError::Authentication { message, .. }) => {
eprintln!("Auth failed: {message:?}");
}
Err(QueryError::RateLimit { message, .. }) => {
eprintln!("Rate limited: {message:?}");
}
Err(e) => eprintln!("Error: {e}"),
}| Feature | Default | Description |
|---|---|---|
reqwest |
no | reqwest 0.13 HTTP client (no TLS) |
reqwest-native-tls |
no | reqwest 0.13 with native TLS |
reqwest-rustls |
no | reqwest 0.13 with rustls TLS |
reqwest-012 |
no | reqwest 0.12 HTTP client (no TLS) |
reqwest-012-native-tls |
no | reqwest 0.12 with native TLS |
reqwest-012-rustls |
no | reqwest 0.12 with rustls TLS |
The plain reqwest feature (and its two TLS variants) tracks the latest reqwest, currently 0.13. To use your own HTTP client, implement the Client trait and skip the reqwest features entirely — useful if you need a different HTTP client like ureq or hyper.
The bundled client supports both reqwest majors. Enable the reqwest-012 feature (with reqwest-012-native-tls or reqwest-012-rustls for a TLS backend) to build LettermintClient against reqwest 0.12 instead of 0.13:
[dependencies]
lettermint = { version = "0.3", default-features = false, features = ["reqwest-012-rustls"] }The two majors are mutually exclusive: enabling a reqwest-012* feature together with reqwest, reqwest-rustls, or any other 0.13 feature is a compile error. The client's own reqwest::backend re-export points at whichever version you selected, so you never name the version directly. If you'd rather bring your own client entirely, implement Client as shown below (it works unchanged against either version, since both build on http/bytes v1).
use bytes::Bytes;
use http::{Request, Response};
use lettermint::Client;
struct MyClient {
api_token: String,
http: reqwest::Client, // any version
}
#[derive(Debug, thiserror::Error)]
enum MyClientError {
#[error("http: {0}")]
Http(#[from] http::Error),
#[error("request: {0}")]
Request(#[from] reqwest::Error),
#[error("header: {0}")]
Header(#[from] http::header::InvalidHeaderValue),
}
impl Client for MyClient {
type Error = MyClientError;
async fn execute(&self, mut req: Request<Bytes>) -> Result<Response<Bytes>, Self::Error> {
req.headers_mut()
.append("x-lettermint-token", self.api_token.as_str().try_into()?);
let base = "https://api.lettermint.co/v1";
let path = req.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("");
*req.uri_mut() = format!("{base}/{}", path.trim_start_matches('/')).parse().unwrap();
let rr: reqwest::Request = req.try_into()?;
let rsp = self.http.execute(rr).await?;
let mut builder = Response::builder().status(rsp.status());
for (k, v) in rsp.headers() {
builder.headers_mut().unwrap().insert(k, v.clone());
}
Ok(builder.body(rsp.bytes().await?)?)
}
}Unit tests. The two reqwest majors are mutually exclusive, so test each backend separately (--all-features does not compile):
cargo test --no-default-features --features reqwest-013-rustls
cargo test --no-default-features --features reqwest-012-rustlsThe testing::emails module provides Lettermint test addresses that simulate delivery scenarios without affecting quotas or bounce rates:
use lettermint::testing::emails::{self, Scenario};
// Fixed addresses for each scenario
let ok = Scenario::Ok.email(); // [email protected]
let bounce = Scenario::HardBounce.email(); // [email protected]
// Unique addresses for CI
let unique = Scenario::SoftBounce.random(); // softbounce+{unique}@testing.lettermint.co
// Custom local part
let tagged = emails::custom("ok+ci"); // [email protected]Available scenarios: Ok, SoftBounce, HardBounce, SpamComplaint, Dsn.
Integration tests hit the live Lettermint API using test addresses that don't count toward quotas:
LETTERMINT_API_TOKEN=your-token \
[email protected] \
cargo test --test integration --no-default-features --features reqwest-013-rustls -- --ignoredLicensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.