- Rust 98.9%
- Scheme 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| src | ||
| tests | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| manifest.scm | ||
| README.md | ||
Lettermint
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.
Usage
[dependencies]
lettermint = { version = "0.1", features = ["reqwest-rustls"] }
tokio = { version = "1", features = ["rt", "macros"] }
Send an email
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("sender@yourdomain.com")
.to(vec!["recipient@example.com".into()])
.subject("Hello from Lettermint")
.text("Plain text body")
.build();
let resp = req.execute(&client).await.unwrap();
println!("Sent: {} ({})", resp.message_id, resp.status);
}
HTML + text with all options
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 <jane@yourdomain.com>")
.to(vec!["user@example.com".into()])
.subject("Monthly update")
.html("<h1>Update</h1><p>Here's what happened.</p>")
.text("Here's what happened.")
.cc(vec!["team@example.com".into()])
.bcc(vec!["archive@example.com".into()])
.reply_to(vec!["support@yourdomain.com".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);
}
Batch sending
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("sender@yourdomain.com")
.to(vec!["alice@example.com".into()])
.subject("Hello Alice")
.text("Hi Alice!")
.build(),
SendEmailRequest::builder()
.from("sender@yourdomain.com")
.to(vec!["bob@example.com".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);
}
}
Ping
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);
}
Webhook verification
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);
Error handling
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}"),
}
Features
| 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.
Using reqwest 0.12
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).
Custom HTTP client example
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?)?)
}
}
Testing
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-rustls
Test email addresses
The 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(); // ok@testing.lettermint.co
let bounce = Scenario::HardBounce.email(); // hardbounce@testing.lettermint.co
// Unique addresses for CI
let unique = Scenario::SoftBounce.random(); // softbounce+{unique}@testing.lettermint.co
// Custom local part
let tagged = emails::custom("ok+ci"); // ok+ci@testing.lettermint.co
Available scenarios: Ok, SoftBounce, HardBounce, SpamComplaint, Dsn.
Integration tests
Integration tests hit the live Lettermint API using test addresses that don't count toward quotas:
LETTERMINT_API_TOKEN=your-token \
LETTERMINT_SENDER=you@yourdomain.com \
cargo test --test integration --no-default-features --features reqwest-013-rustls -- --ignored
License
Licensed 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.