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:
- Go to your dashboard
- Create a new API key (store this securely - you won't see it again!)
- Verify your domain or use the
resend.devdomain for testing - 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
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
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
Add .env to your .gitignore to prevent accidentally committing sensitive information:
echo ".env" >> .gitignore
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
}
}
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();
}
Step 6: Testing the Email Service
Let's test our email service. First, run the application:
cargo run
You should see output like:
2024-01-15T10:30:45Z INFO Starting email service on http://127.0.0.1:3000
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."
}'
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"
}'
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"
}'
Health check:
curl http://localhost:3000/health
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);
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
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);
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()),
}
}
}
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"]
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
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
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/*
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
- Add email templates: Store HTML templates in files for easier management
- Implement webhooks: Handle email delivery events from Resend
- Add rate limiting: Prevent abuse with proper rate limiting
- Add authentication: Secure your API endpoints with JWT or API keys
- Add monitoring: Integrate with Prometheus/Grafana for metrics
- Add testing: Write unit and integration tests for email functionality
Top comments (0)