Thanks to visit codestin.com
Credit goes to github.com

Skip to content

franzos/lettermint-rs

Repository files navigation

Lettermint

ci crates.io Documentation

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("[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);
}

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 <[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);
}

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("[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);
    }
}

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();            // [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

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 -- --ignored

License

Licensed under either of

at your option.

About

Lettermint client for rust

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

2 stars

Watchers

0 watching

Forks

Contributors