DEV Community

Cover image for Sending Emails with Rust, Axum, and Resend: A Complete Guide
Mayuresh
Mayuresh

Posted on

Sending Emails with Rust, Axum, and Resend: A Complete Guide

In today's web applications, sending emails is a fundamental requirement - from user verification to password resets, notifications, and marketing campaigns. While Rust is known for its performance and safety, sending emails has traditionally been more complex than in other ecosystems. That's where Resend comes in - a modern email API designed for developers that integrates beautifully with Rust applications.

In this tutorial, we'll build a complete Axum web server that can send transactional emails using Resend. By the end, you'll have a production-ready email sending service that you can integrate into your Rust applications.

Why Resend?

Before we dive in, let's understand why Resend is an excellent choice for Rust developers:

  • Simple API: Clean, RESTful API with excellent documentation
  • TypeScript-first: But has excellent Rust support through community libraries
  • Reliable delivery: High deliverability rates with spam monitoring
  • Developer experience: Real-time logs, analytics, and easy debugging
  • Free tier: Generous free tier for development and small projects
  • Rust ecosystem: Growing Rust library support

Prerequisites

Before we start, make sure you have:

  • Rust installed (1.70+)
  • Cargo (comes with Rust)
  • A Resend account (free tier available)
  • Basic understanding of Axum and async Rust

Step 1: Setting Up Your Resend Account

First, create a free account at resend.com. Once logged in:

  1. Go to your dashboard
  2. Create a new API key (store this securely - you won't see it again!)
  3. Verify your domain or use the resend.dev domain for testing
  4. Add at least one sender email address (this needs verification)

For development purposes, you can use the resend.dev domain which doesn't require DNS verification. Your sender email will look like: username@resend.dev.

Step 2: Creating a New Axum Project

Let's create a new Rust project:

cargo new resend-email-service cd resend-email-service 
Enter fullscreen mode Exit fullscreen mode

Update your Cargo.toml with the necessary dependencies:

[package] name = "resend-email-service" version = "0.1.0" edition = "2021" [dependencies] axum = { version = "0.7", features = ["json", "macros"] } resend-rs = "0.3" # Official Resend Rust client tokio = { version = "1.0", features = ["full"] } tower = { version = "0.4", features = ["util"] } tower-http = { version = "0.5", features = ["cors"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" tracing = "0.1" tracing-subscriber = "0.3" uuid = { version = "1.0", features = ["v4"] } dotenvy = "0.15" # For environment variables 
Enter fullscreen mode Exit fullscreen mode

Step 3: Setting Up Environment Variables

Create a .env file in your project root:

RESEND_API_KEY=your_resend_api_key_here FROM_EMAIL=your_verified_sender@resend.dev APP_PORT=3000 
Enter fullscreen mode Exit fullscreen mode

Add .env to your .gitignore to prevent accidentally committing sensitive information:

echo ".env" >> .gitignore 
Enter fullscreen mode Exit fullscreen mode

Step 4: Creating the Email Service

Let's create a dedicated email service module. Create a new file src/email_service.rs:

use resend_rs::{Client, requests::SendEmailRequest}; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Debug, Error)] pub enum EmailError { #[error("Failed to send email: {0}")] SendFailed(String), #[error("Invalid email configuration: {0}")] ConfigurationError(String), } #[derive(Debug, Serialize, Deserialize)] pub struct EmailTemplate { pub subject: String, pub html_content: String, pub text_content: Option<String>, } #[derive(Debug, Serialize, Deserialize)] pub struct EmailRecipient { pub email: String, pub name: Option<String>, } pub struct EmailService { resend_client: Client, from_email: String, } impl EmailService { pub fn new(api_key: &str, from_email: &str) -> Self { let client = Client::new(api_key); Self { resend_client: client, from_email: from_email.to_string(), } } pub async fn send_email( &self, recipient: EmailRecipient, template: EmailTemplate, ) -> Result<(), EmailError> { let request = SendEmailRequest::builder() .from(&self.from_email) .to([&recipient.email]) .subject(&template.subject) .html(&template.html_content) .text(template.text_content.as_deref().unwrap_or("")) .build() .map_err(|e| EmailError::ConfigurationError(e.to_string()))?; let result = self.resend_client.emails.send(request).await; match result { Ok(_) => Ok(()), Err(e) => Err(EmailError::SendFailed(e.to_string())), } } pub async fn send_verification_email( &self, recipient: EmailRecipient, verification_code: &str, ) -> Result<(), EmailError> { let template = EmailTemplate { subject: "Verify Your Email Address".to_string(), html_content: format!( r#" <h1>Verify Your Email</h1> <p>Your verification code is: <strong>{}</strong></p> <p>This code will expire in 10 minutes.</p> <p>If you didn't request this verification, please ignore this email.</p> "#, verification_code ), text_content: Some(format!( "Verify Your Email\n\nYour verification code is: {}\nThis code will expire in 10 minutes.", verification_code )), }; self.send_email(recipient, template).await } pub async fn send_password_reset_email( &self, recipient: EmailRecipient, reset_link: &str, ) -> Result<(), EmailError> { let template = EmailTemplate { subject: "Reset Your Password".to_string(), html_content: format!( r#" <h1>Reset Your Password</h1> <p>Click the link below to reset your password:</p> <a href="{}" style="background-color: #0066ff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 20px 0;"> Reset Password </a> <p>This link will expire in 1 hour.</p> <p>If you didn't request a password reset, please ignore this email.</p> "#, reset_link ), text_content: Some(format!( "Reset Your Password\n\nClick this link to reset your password:\n{}\nThis link will expire in 1 hour.", reset_link )), }; self.send_email(recipient, template).await } } 
Enter fullscreen mode Exit fullscreen mode

Step 5: Creating the Axum Application

Now let's create the main application. Update src/main.rs:

use axum::{ extract::State, http::StatusCode, response::Json, routing::{get, post}, Router, }; use email_service::{EmailError, EmailRecipient, EmailService}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{info, error}; use uuid::Uuid; mod email_service; #[derive(Debug, Serialize, Deserialize)] struct EmailRequest { to_email: String, to_name: Option<String>, subject: String, html_content: String, text_content: Option<String>, } #[derive(Debug, Serialize, Deserialize)] struct VerificationRequest { email: String, name: Option<String>, } #[derive(Debug, Serialize, Deserialize)] struct PasswordResetRequest { email: String, name: Option<String>, reset_url: String, } #[derive(Debug, Serialize)] struct ApiResponse { success: bool, message: String, } #[derive(Debug, Serialize)] struct ApiError { success: false, error: String, } #[derive(Clone)] struct AppState { email_service: Arc<EmailService>, } async fn send_custom_email( State(state): State<AppState>, Json(payload): Json<EmailRequest>, ) -> Result<Json<ApiResponse>, (StatusCode, Json<ApiError>)> { let recipient = EmailRecipient { email: payload.to_email.clone(), name: payload.to_name.clone(), }; let template = email_service::EmailTemplate { subject: payload.subject.clone(), html_content: payload.html_content.clone(), text_content: payload.text_content.clone(), }; match state.email_service.send_email(recipient, template).await { Ok(_) => { info!("Successfully sent email to {}", payload.to_email); Ok(Json(ApiResponse { success: true, message: format!("Email sent successfully to {}", payload.to_email), })) } Err(e) => { error!("Failed to send email: {}", e); Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { success: false, error: e.to_string(), }), )) } } } async fn send_verification_email( State(state): State<AppState>, Json(payload): Json<VerificationRequest>, ) -> Result<Json<ApiResponse>, (StatusCode, Json<ApiError>)> { let verification_code = Uuid::new_v4().to_string()[..6].to_uppercase(); let recipient = EmailRecipient { email: payload.email.clone(), name: payload.name.clone(), }; match state.email_service.send_verification_email(recipient, &verification_code).await { Ok(_) => { info!("Sent verification email to {}", payload.email); Ok(Json(ApiResponse { success: true, message: format!("Verification email sent to {}", payload.email), })) } Err(e) => { error!("Failed to send verification email: {}", e); Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { success: false, error: e.to_string(), }), )) } } } async fn send_password_reset_email( State(state): State<AppState>, Json(payload): Json<PasswordResetRequest>, ) -> Result<Json<ApiResponse>, (StatusCode, Json<ApiError>)> { let recipient = EmailRecipient { email: payload.email.clone(), name: payload.name.clone(), }; match state.email_service.send_password_reset_email(recipient, &payload.reset_url).await { Ok(_) => { info!("Sent password reset email to {}", payload.email); Ok(Json(ApiResponse { success: true, message: format!("Password reset email sent to {}", payload.email), })) } Err(e) => { error!("Failed to send password reset email: {}", e); Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { success: false, error: e.to_string(), }), )) } } } async fn health_check() -> Json<ApiResponse> { Json(ApiResponse { success: true, message: "Email service is healthy".to_string(), }) } #[tokio::main] async fn main() { // Initialize tracing tracing_subscriber::fmt::init(); // Load environment variables dotenvy::dotenv().ok(); let resend_api_key = std::env::var("RESEND_API_KEY") .expect("RESEND_API_KEY must be set in .env file"); let from_email = std::env::var("FROM_EMAIL") .expect("FROM_EMAIL must be set in .env file"); let port = std::env::var("APP_PORT") .unwrap_or_else(|_| "3000".to_string()) .parse::<u16>() .expect("APP_PORT must be a valid port number"); // Initialize email service let email_service = EmailService::new(&resend_api_key, &from_email); let app_state = AppState { email_service: Arc::new(email_service), }; // Create router let app = Router::new() .route("/health", get(health_check)) .route("/send-email", post(send_custom_email)) .route("/send-verification", post(send_verification_email)) .route("/send-password-reset", post(send_password_reset_email)) .with_state(app_state); // Start server let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); info!("Starting email service on http://{}", addr); axum::Server::bind(&addr) .serve(app.into_make_service()) .await .unwrap(); } 
Enter fullscreen mode Exit fullscreen mode

Step 6: Testing the Email Service

Let's test our email service. First, run the application:

cargo run 
Enter fullscreen mode Exit fullscreen mode

You should see output like:

2024-01-15T10:30:45Z INFO Starting email service on http://127.0.0.1:3000 
Enter fullscreen mode Exit fullscreen mode

Testing with curl

Send a custom email:

curl -X POST http://localhost:3000/send-email \ -H "Content-Type: application/json" \ -d '{ "to_email": "test@example.com", "to_name": "Test User", "subject": "Hello from Rust!", "html_content": "<h1>Hello!</h1><p>This email was sent from a Rust Axum application using Resend.</p>", "text_content": "Hello! This email was sent from a Rust Axum application using Resend." }' 
Enter fullscreen mode Exit fullscreen mode

Send a verification email:

curl -X POST http://localhost:3000/send-verification \ -H "Content-Type: application/json" \ -d '{ "email": "test@example.com", "name": "Test User" }' 
Enter fullscreen mode Exit fullscreen mode

Send a password reset email:

curl -X POST http://localhost:3000/send-password-reset \ -H "Content-Type: application/json" \ -d '{ "email": "test@example.com", "name": "Test User", "reset_url": "https://yourapp.com/reset-password?token=abc123" }' 
Enter fullscreen mode Exit fullscreen mode

Health check:

curl http://localhost:3000/health 
Enter fullscreen mode Exit fullscreen mode

Step 7: Adding CORS Support (Optional but Recommended)

For production applications, you'll likely need CORS support. Update your main.rs to include CORS middleware:

// Add this import at the top use tower_http::cors::{Any, CorsLayer}; // In the main function, update the router creation: let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); let app = Router::new() .route("/health", get(health_check)) .route("/send-email", post(send_custom_email)) .route("/send-verification", post(send_verification_email)) .route("/send-password-reset", post(send_password_reset_email)) .layer(cors) // Add CORS middleware .with_state(app_state); 
Enter fullscreen mode Exit fullscreen mode

Step 8: Production Considerations

Rate Limiting

Add rate limiting to prevent abuse:

use tower_http::limit::RequestBodyLimitLayer; // Add this to your middleware stack .layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1MB limit 
Enter fullscreen mode Exit fullscreen mode

Error Handling

Improve error handling with custom middleware:

async fn handle_error(err: axum::BoxError) -> Json<ApiError> { error!("Unhandled error: {}", err); Json(ApiError { success: false, error: "Internal server error".to_string(), }) } // In main function: let app = app.fallback(handle_error); 
Enter fullscreen mode Exit fullscreen mode

Environment Configuration

Create a proper configuration struct:

#[derive(Debug, Clone)] struct Config { resend_api_key: String, from_email: String, port: u16, environment: String, } impl Config { fn from_env() -> Self { Self { resend_api_key: std::env::var("RESEND_API_KEY") .expect("RESEND_API_KEY must be set"), from_email: std::env::var("FROM_EMAIL") .expect("FROM_EMAIL must be set"), port: std::env::var("APP_PORT") .unwrap_or_else(|_| "3000".to_string()) .parse() .expect("APP_PORT must be a valid port"), environment: std::env::var("ENVIRONMENT") .unwrap_or_else(|_| "development".to_string()), } } } 
Enter fullscreen mode Exit fullscreen mode

Step 9: Deployment

Dockerfile

Create a Dockerfile for easy deployment:

FROM rust:1.75 as builder WORKDIR /app COPY Cargo.toml Cargo.lock ./ COPY src ./src RUN cargo build --release FROM debian:bookworm-slim WORKDIR /app COPY --from=builder /app/target/release/resend-email-service . COPY .env.example .env EXPOSE 3000 CMD ["./resend-email-service"] 
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml

version: '3.8' services: email-service: build: . ports: - "3000:3000" environment: - RESEND_API_KEY=${RESEND_API_KEY} - FROM_EMAIL=${FROM_EMAIL} - APP_PORT=3000 restart: unless-stopped 
Enter fullscreen mode Exit fullscreen mode

Complete Project Structure

Your final project structure should look like this:

resend-email-service/ ├── .env.example ├── .gitignore ├── Cargo.toml ├── Dockerfile ├── docker-compose.yml ├── src/ │ ├── main.rs │ └── email_service.rs └── README.md 
Enter fullscreen mode Exit fullscreen mode

Troubleshooting Common Issues

1. "No space left on device" Error

If you encounter disk space issues during compilation (as mentioned in your error log), clean your Cargo cache:

cargo clean rm -rf ~/.cargo/registry/cache/* rm -rf ~/.cargo/registry/src/* 
Enter fullscreen mode Exit fullscreen mode

2. Resend API Key Issues

  • Ensure your API key has the correct permissions
  • Verify your sender email address is confirmed in the Resend dashboard
  • Check that you're using the correct environment (test vs production)

3. CORS Issues in Production

  • Configure proper CORS origins instead of Any
  • Consider using a reverse proxy like Nginx for additional security

4. Email Delivery Issues

  • Check Resend dashboard logs for delivery status
  • Ensure your domain is properly configured if not using resend.dev
  • Monitor spam complaints and adjust email content accordingly

Conclusion

You've successfully built a production-ready email service using Rust, Axum, and Resend! This service provides:

  • ✅ Clean, type-safe email sending functionality
  • ✅ Multiple email templates (verification, password reset, custom)
  • ✅ Proper error handling and logging
  • ✅ Health checks and monitoring
  • ✅ Docker support for easy deployment
  • ✅ Environment configuration management

This foundation can be extended to support:

  • Email templates stored in files
  • Attachment support
  • Email scheduling
  • Analytics and reporting
  • Webhook handling for email events (opens, clicks, bounces)

The combination of Rust's performance and safety with Resend's excellent developer experience creates a powerful email sending solution that can scale with your application needs.

Next Steps

  1. Add email templates: Store HTML templates in files for easier management
  2. Implement webhooks: Handle email delivery events from Resend
  3. Add rate limiting: Prevent abuse with proper rate limiting
  4. Add authentication: Secure your API endpoints with JWT or API keys
  5. Add monitoring: Integrate with Prometheus/Grafana for metrics
  6. Add testing: Write unit and integration tests for email functionality

Resources

Top comments (0)