Skip to main content
This guide covers advanced features of the Lettr Rust SDK including attachments, templates, batch sending, error handling, and best practices for production applications.
New to the Rust SDK? Start with the Rust Quickstart to learn the basics first.

Advanced Features

CC and BCC Recipients

Add CC and BCC recipients:
use lettr::{Lettr, CreateEmailOptions};

let email = CreateEmailOptions::new(
    "notifications@yourdomain.com",
    ["primary@example.com"],
    "Team notification",
)
.with_cc(["cc@example.com", "cc2@example.com"])
.with_bcc(["bcc@example.com"])
.with_html("<p>This email has CC and BCC recipients.</p>");

client.emails.send(email).await?;
BCC recipients are hidden from all other recipients. They receive the email but their addresses are not visible in the headers.

Reply-To Address

Specify a different reply-to address:
let email = CreateEmailOptions::new(
    "notifications@yourdomain.com",
    ["user@example.com"],
    "Support notification",
)
.with_reply_to("support@yourdomain.com")
.with_html("<p>Click reply to contact our support team.</p>");

client.emails.send(email).await?;

Attachments

Add file attachments to your emails:
use lettr::{Lettr, CreateEmailOptions, Attachment};
use std::fs;

// Read file and encode to base64
let file_data = fs::read("invoice.pdf")?;
let encoded = base64::encode(&file_data);

let email = CreateEmailOptions::new(
    "billing@yourdomain.com",
    ["customer@example.com"],
    "Your invoice",
)
.with_html("<p>Please find your invoice attached.</p>")
.with_attachments(vec![
    Attachment {
        filename: "invoice.pdf".to_string(),
        content: encoded,
        content_type: "application/pdf".to_string(),
    }
]);

client.emails.send(email).await?;
Attach multiple files:
use base64::{Engine as _, engine::general_purpose};

let pdf_data = fs::read("document.pdf")?;
let img_data = fs::read("logo.png")?;

let attachments = vec![
    Attachment {
        filename: "document.pdf".to_string(),
        content: general_purpose::STANDARD.encode(&pdf_data),
        content_type: "application/pdf".to_string(),
    },
    Attachment {
        filename: "logo.png".to_string(),
        content: general_purpose::STANDARD.encode(&img_data),
        content_type: "image/png".to_string(),
    },
];

let email = CreateEmailOptions::new(
    "sender@yourdomain.com",
    ["recipient@example.com"],
    "Files attached",
)
.with_html("<p>See attached files.</p>")
.with_attachments(attachments);

client.emails.send(email).await?;
Attachments must be base64-encoded. The total size of all attachments should not exceed 10MB. Larger files should be hosted and linked instead.

Templates

Send emails using Lettr-managed templates:
use std::collections::HashMap;

let mut merge_tags = HashMap::new();
merge_tags.insert("name".to_string(), "John Doe".into());
merge_tags.insert("company".to_string(), "Acme Corp".into());
merge_tags.insert("verify_url".to_string(), "https://example.com/verify/abc123".into());
merge_tags.insert("support_email".to_string(), "support@yourdomain.com".into());

let email = CreateEmailOptions::new(
    "notifications@yourdomain.com",
    ["user@example.com"],
    "Welcome", // Subject can be overridden by template
)
.with_template_id("welcome-email")
.with_merge_tags(merge_tags);

client.emails.send(email).await?;
Templates are managed in the Lettr dashboard. Use merge tags to personalize content without rebuilding HTML in your code.

Custom Headers

Add custom email headers:
use std::collections::HashMap;

let mut headers = HashMap::new();
headers.insert("X-Campaign-ID".to_string(), "summer-2024".to_string());
headers.insert("X-Priority".to_string(), "high".to_string());
headers.insert("X-Mailer".to_string(), "Acme Mailer v1.0".to_string());

let email = CreateEmailOptions::new(
    "notifications@yourdomain.com",
    ["user@example.com"],
    "Custom headers example",
)
.with_html("<p>This email has custom headers.</p>")
.with_headers(headers);

client.emails.send(email).await?;

Tracking

Enable open and click tracking:
let email = CreateEmailOptions::new(
    "marketing@yourdomain.com",
    ["user@example.com"],
    "Newsletter",
)
.with_html("<p>Check out <a href='https://example.com'>our website</a>!</p>")
.with_click_tracking(true)
.with_open_tracking(true);

client.emails.send(email).await?;
Open tracking works by embedding a transparent pixel image. Click tracking rewrites links to go through Lettr’s tracking domain. Both features respect user privacy and comply with email regulations.

Metadata

Attach custom metadata for tracking and filtering:
use std::collections::HashMap;

let mut metadata = HashMap::new();
metadata.insert("user_id".to_string(), "12345".to_string());
metadata.insert("order_id".to_string(), "ORD-98765".to_string());
metadata.insert("campaign".to_string(), "abandoned-cart".to_string());
metadata.insert("environment".to_string(), "production".to_string());

let email = CreateEmailOptions::new(
    "notifications@yourdomain.com",
    ["user@example.com"],
    "Order confirmation",
)
.with_html("<p>Your order has been confirmed.</p>")
.with_metadata(metadata);

client.emails.send(email).await?;
Metadata is returned in webhook events and can be used to correlate emails with your application data.

Error Handling

The SDK provides idiomatic Rust error handling with the Result type:
use lettr::{Lettr, CreateEmailOptions, Error};

let email = CreateEmailOptions::new(
    "invalid@unverified-domain.com",
    ["user@example.com"],
    "Test",
)
.with_html("<p>Test</p>");

match client.emails.send(email).await {
    Ok(response) => {
        println!("Email sent: {}", response.request_id);
    }
    Err(Error::Validation(err)) => {
        // 422 validation errors
        eprintln!("Validation failed: {}", err.message);
        for (field, messages) in err.errors {
            eprintln!("  {}: {:?}", field, messages);
        }
    }
    Err(Error::Authentication(err)) => {
        // 401 authentication errors
        eprintln!("Authentication failed: {}", err.message);
    }
    Err(Error::RateLimit(err)) => {
        // 429 rate limit errors
        eprintln!("Rate limit exceeded: {}", err.message);
    }
    Err(Error::Api(err)) => {
        // Other API errors (500, 503, etc.)
        eprintln!("API error: {}", err.message);
    }
    Err(err) => {
        // Network or other errors
        eprintln!("Request failed: {}", err);
    }
}

Common Error Scenarios

Unverified domain:
  • Error: Validation error
  • Message: “The from address domain is not verified”
  • Solution: Verify your domain in the dashboard
Invalid API key:
  • Error: Authentication error
  • Message: “Invalid API key”
  • Solution: Check your API key is correct and active
Rate limit exceeded:
  • Error: RateLimit error
  • Message: “Too many requests”
  • Solution: Implement exponential backoff retry logic

Batch Sending

Send multiple emails concurrently using Tokio’s join patterns:
use lettr::{Lettr, CreateEmailOptions};
use tokio;

#[tokio::main]
async fn main() -> lettr::Result<()> {
    let client = Lettr::from_env();

    let recipients = vec![
        "user1@example.com",
        "user2@example.com",
        "user3@example.com",
    ];

    let mut tasks = vec![];

    for recipient in recipients {
        let client = client.clone();
        let task = tokio::spawn(async move {
            let email = CreateEmailOptions::new(
                "notifications@yourdomain.com",
                [recipient],
                "Batch notification",
            )
            .with_html("<p>This is a batch email.</p>");

            match client.emails.send(email).await {
                Ok(response) => println!("Sent to {}: {}", recipient, response.request_id),
                Err(e) => eprintln!("Failed to send to {}: {}", recipient, e),
            }
        });

        tasks.push(task);
    }

    // Wait for all tasks to complete
    for task in tasks {
        task.await?;
    }

    println!("All emails sent");

    Ok(())
}

With Concurrency Limiting

Use a semaphore to limit concurrent requests:
use tokio::sync::Semaphore;
use std::sync::Arc;

async fn send_batch(
    client: &Lettr,
    recipients: Vec<&str>,
    max_concurrent: usize,
) -> lettr::Result<()> {
    let semaphore = Arc::new(Semaphore::new(max_concurrent));
    let mut tasks = vec![];

    for recipient in recipients {
        let client = client.clone();
        let permit = semaphore.clone().acquire_owned().await?;

        let task = tokio::spawn(async move {
            let email = CreateEmailOptions::new(
                "notifications@yourdomain.com",
                [recipient],
                "Batch email",
            )
            .with_html("<p>Hello!</p>");

            let result = client.emails.send(email).await;
            drop(permit); // Release semaphore

            result
        });

        tasks.push(task);
    }

    for task in tasks {
        task.await??;
    }

    Ok(())
}

// Usage
let recipients = vec!["user1@example.com", "user2@example.com", "user3@example.com"];
send_batch(&client, recipients, 10).await?; // Max 10 concurrent requests
For large batches, consider using a task queue or stream-based approach with futures::stream::StreamExt to manage concurrency and handle failures gracefully.

Async Patterns

Using with Tokio Runtime

The SDK is built on Tokio and works with any Tokio-based application:
use lettr::Lettr;

#[tokio::main]
async fn main() -> lettr::Result<()> {
    let client = Lettr::from_env();
    // Your async code here
    Ok(())
}

Using with Actix Web

Integrate with Actix Web handlers:
use actix_web::{web, HttpResponse, Result};
use lettr::{Lettr, CreateEmailOptions};

async fn send_email(
    client: web::Data<Lettr>,
    recipient: web::Path<String>,
) -> Result<HttpResponse> {
    let email = CreateEmailOptions::new(
        "notifications@yourdomain.com",
        [recipient.as_str()],
        "Hello from Actix",
    )
    .with_html("<p>Hello!</p>");

    match client.emails.send(email).await {
        Ok(response) => Ok(HttpResponse::Ok().json(response)),
        Err(e) => Ok(HttpResponse::InternalServerError().body(e.to_string())),
    }
}

Using with Axum

Integrate with Axum handlers:
use axum::{extract::State, Json};
use lettr::{Lettr, CreateEmailOptions};

async fn send_email(
    State(client): State<Lettr>,
    recipient: String,
) -> Result<Json<String>, String> {
    let email = CreateEmailOptions::new(
        "notifications@yourdomain.com",
        [recipient.as_str()],
        "Hello from Axum",
    )
    .with_html("<p>Hello!</p>");

    match client.emails.send(email).await {
        Ok(response) => Ok(Json(response.request_id)),
        Err(e) => Err(e.to_string()),
    }
}

Best Practices

Use Environment Variables

Never hardcode API keys. Use environment variables or a secrets manager:
use std::env;

let api_key = env::var("LETTR_API_KEY")
    .expect("LETTR_API_KEY is required");

let client = Lettr::new(&api_key);

Validate Before Sending

Validate email addresses before making API calls:
use validator::validate_email;

fn is_valid_email(email: &str) -> bool {
    validate_email(email)
}

if !is_valid_email(&recipient) {
    eprintln!("Invalid email address: {}", recipient);
    return Err("Invalid email".into());
}

Log Request IDs

Always log the request_id from successful sends for tracking and debugging:
use log::info;

let response = client.emails.send(email).await?;
info!("Email sent successfully - Request ID: {}, Accepted: {}",
    response.request_id, response.accepted);

Handle Errors Gracefully

Implement retry logic for transient errors:
use tokio::time::{sleep, Duration};

async fn send_with_retry(
    client: &Lettr,
    email: CreateEmailOptions,
    max_retries: usize,
) -> lettr::Result<String> {
    for attempt in 0..=max_retries {
        match client.emails.send(email.clone()).await {
            Ok(response) => {
                println!("Email sent: {}", response.request_id);
                return Ok(response.request_id);
            }
            Err(Error::Validation(e)) => {
                // Don't retry validation errors
                return Err(Error::Validation(e));
            }
            Err(e) if attempt < max_retries => {
                let backoff = Duration::from_secs((attempt + 1) as u64);
                eprintln!("Attempt {} failed, retrying in {:?}: {}", attempt + 1, backoff, e);
                sleep(backoff).await;
            }
            Err(e) => return Err(e),
        }
    }

    unreachable!()
}

Clone the Client

The Lettr client is cheap to clone and can be shared across tasks:
let client = Lettr::from_env();

// Clone for use in spawned task
let client_clone = client.clone();
tokio::spawn(async move {
    // Use client_clone
});

Use Structured Logging

Use the tracing crate for structured logging:
use tracing::{info, error};

match client.emails.send(email).await {
    Ok(response) => {
        info!(
            request_id = %response.request_id,
            accepted = response.accepted,
            "Email sent successfully"
        );
    }
    Err(e) => {
        error!(error = %e, "Failed to send email");
    }
}

Troubleshooting

If you see “The from address domain is not verified”:
  • Verify your domain in the Lettr dashboard
  • Ensure the from address uses the verified domain
  • Wait for DNS propagation (can take up to 48 hours)
  • See Domain Verification for help
If you see authentication errors:
  • Check your API key is correct and starts with lttr_
  • Verify the key is 68 characters total (prefix + 64 hex chars)
  • Ensure the key hasn’t been revoked in the dashboard
  • Confirm you’re reading from the correct environment variable
If requests timeout:
  • Increase the client timeout (default may be too short)
  • Check your network connectivity and firewall settings
  • Verify app.lettr.com is reachable
  • Use a custom reqwest client with longer timeout settings
If you see compilation errors:
  • Run cargo update to update dependencies
  • Check your Rust version is 1.70 or later: rustc --version
  • Verify the crate name is correct: lettr
  • Clear build artifacts: cargo clean
If you’re hitting rate limits:
  • Implement exponential backoff retry logic (see Best Practices)
  • Use async batch sending with controlled concurrency via semaphore
  • Consider upgrading your Lettr plan for higher limits
  • Spread requests over time instead of bursts
If you encounter Rust-specific type errors:
  • Ensure your async runtime (Tokio) is properly configured
  • Check that you’re using .await on async functions
  • Use .clone() when sharing the client across tasks
  • Verify all required fields are provided to the builder

What’s Next