Kirim email di Rust tidak serumit yang developerbayangkan. Lettre crate menyediakan async SMTP client yang bersih, tapi tutorial kebanyakan hanya sampai basic send. Artikel ini membahas setup async dengan tokio, connection pool management, retry logic dengan exponential backoff, dan cara handle error yang proper untuk production-grade email sender.
Daftar Isi
Kenapa async email di Rust?
Aplikasi backend modern ditulis dengan async runtime. Kalau Anda pakai tokio untuk handle concurrency, email sending yang blocking adalah bottleneck. AsyncSmtpTransport di Lettre dibuat untuk situasi ini: koneksi SMTP dikelola secara async, tidak memblokir thread saat menunggu response dari mail server.
Berbeda dengan library email di bahasa lain yang menyediakan retry bawaan, Lettre fokus pada low-level email composition dan transport. Yang berarti Anda punya kontrol penuh atas retry logic, connection pool, dan error handling. Kelebihannya adalah Anda bisa bikin sistem email yang benar-benar resilient terhadap network failure.
Setup Awal: Cargo.toml dan Dependency
Untuk mulai, tambahkan dependency berikut di Cargo.toml:
[dependencies]
lettre = { version = "0.11", features = ["tokio1-native-tls", "async-std1"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
tokio1-native-tls dibutuhkan karena AsyncSmtpTransport menggunakan TLS untuk koneksi yang aman. Tanpa fitur ini, koneksi ke port 465 atau 587 dengan STARTTLS akan gagal.
Konfigurasi AsyncSmtpTransport dengan Connection Pool
AsyncSmtpTransport di Lettre punya fitur connection pool yang dikelola secara otomatis ketika Anda enable feature pool. Berikut konfigurasi yang production-ready:
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::commands::SmtpCommand;
use lettre::{AsyncStd1Executor, Tokio1Executor};
type SmtpTransport = AsyncSmtpTransport<Tokio1Executor>;
pub fn create_smtp_transport(smtp_host: &str, smtp_port: u16) -> SmtpTransport {
AsyncSmtpTransport::builder_new((smtp_host, smtp_port).into())
.tls_openssl()
.unwrap()
.build()
}
AsyncSmtpTransport<Tokio1Executor> berarti setiap operasi send akan dijalankan di tokio runtime. TLS dengan OpenSSL dipilih karena lebih stabil di production dibandingkan native-tls di beberapa platform.
Port default untuk SMTP adalah 587 untuk STARTTLS, atau 465 untuk SMTPS (implicit TLS). Keduanya didukung oleh builder_new, tapi Anda perlu specify TLS mode yang sesuai:
// Untuk port 587 (STARTTLS)
.tls_openssl()
// Untuk port 465 (SMTPS)
.tls_native()
Kirim Email: Fungsi Async Send dengan Proper Error Handling
Berikut fungsi lengkap untuk kirim email dengan error handling yang proper:
use anyhow::Result;
use lettre::message::{Message, Mailbox};
use lettre::transport::smtp::AsyncSmtpTransport;
pub async fn send_email(
transport: &AsyncSmtpTransport<Tokio1Executor>,
to: &str,
subject: &str,
body: &str,
) -> Result<()> {
let email = Message::builder()
.from("[email protected]".parse::<Mailbox>().unwrap())
.to(to.parse::<Mailbox>().unwrap())
.subject(subject)
.text_body(body)
.unwrap();
transport.send(email).await?;
Ok(())
}
Fungsi ini mengembalikan Result<()> sehingga caller bisa decide apa yang dilakukan ketika email gagal dikirim. Untuk production, Anda tidak mau fungsi ini langsung panic kalau SMTP server timeout.
Retry Logic dengan Exponential Backoff
Lettre tidak menyediakan retry logic bawaan. Anda perlu mengimplementasikan sendiri di application layer. Berikut pattern yang production-ready:
use anyhow::Result;
use tokio::time::{sleep, Duration};
use rand::Rng;
pub async fn send_email_with_retry(
transport: &AsyncSmtpTransport<Tokio1Executor>,
to: &str,
subject: &str,
body: &str,
max_retries: u32,
) -> Result<()> {
let mut attempt = 0;
let mut last_error = None;
while attempt < max_retries {
match send_email(transport, to, subject, body).await {
Ok(()) => return Ok(()),
Err(e) => {
attempt += 1;
last_error = Some(e);
if attempt >= max_retries {
break;
}
// Exponential backoff dengan jitter
let base_delay = Duration::from_secs(2_u64.pow(attempt));
let jitter = Duration::from_millis(rand::thread_rng().gen_range(0..500));
sleep(base_delay + jitter).await;
}
}
}
Err(anyhow::anyhow!("Email gagal dikirim setelah {} percobaan: {:?}", max_retries, last_error))
}
Penjelasan retry strategy di atas:
2_u64.pow(attempt)berarti delay pertama 2 detik, kedua 4 detik, ketiga 8 detik- Jitter 0-500ms ditambahkan agar tidak ada thundering herd problem ketika banyak proses retry bersamaan
- Maximum retry yang wajar untuk email transaction adalah 3-5 kali, karena setelah itu kemungkinan SMTP server memang tidak bisa terima email tersebut
Handle Specific SMTP Error Codes
Tidak semua error butuh retry. Anda perlu membedakan antara temporary failure yang bisa di-retry dan permanent failure yang tidak akan pernah berhasil:
use anyhow::Result;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::Error;
pub async fn classify_and_handle_error(err: Error) -> Result<()> {
match &err {
Error::AuthenticationFailed => {
// Tidak ada gunanya retry — credential Anda salah
Err(anyhow::anyhow!("SMTP authentication gagal. Periksa credential Anda."))
}
Error::Response(response) => {
let code = response.code().0;
match code {
// 450: Mailbox unavailable — permanent failure, tidak perlu retry
450 | 550 => {
Err(anyhow::anyhow!("Email ditolak: mailbox unavailable"))
}
// 451: Server error — temporary, layak retry
451 => Err(anyhow::anyhow!("Temporary server error — retry needed")),
// 452: Storage full — temporary, layak retry
452 => Err(anyhow::anyhow!("Recipient storage full — retry later")),
_ => Err(anyhow::anyhow!("SMTP error code {}: {:?}", code, response.message())),
}
}
_ => {
// Connection error, timeout, dll — layak retry
Err(anyhow::anyhow!("Connection error: {:?}", err))
}
}
}
Kode SMTP yang paling sering muncul:
550: Mailbox tidak ada atau blocked, permanent, hapus dari list451/452: Temporary issue, retry dengan backoff421: Server overload, retry biasanya berhasil setelah beberapa detik
Integration dengan Tokio Runtime
Untuk aplikasi yang sudah pakai tokio, berikut pola integration yang clean:
use tokio::task::JoinSet;
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
let transport = create_smtp_transport("smtp.example.com", 587);
let mut set = JoinSet::new();
// Kirim 100 email secara concurrent dengan limitasi
for i in 0..100 {
let t = transport.clone();
set.spawn(async move {
send_email_with_retry(&t, "[email protected]", "Subject", "Body", 3).await
});
}
while let Some(result) = set.join_next().await {
match result {
Ok(Ok(())) => println!("Email terkirim"),
Ok(Err(e)) => println!("Gagal: {}", e),
Err(e) => println!("Task error: {}", e),
}
}
Ok(())
}
Concurrency limit yang realistis untuk SMTP relay adalah 10-20 koneksi concurrent. Tokio JoinSet dengan semaphore bisa dipakai untuk limitasi ini:
use tokio::sync::Semaphore;
async fn send_with_limit(
semaphore: &Semaphore,
transport: &AsyncSmtpTransport<Tokio1Executor>,
to: &str,
) -> Result<()> {
let _permit = semaphore.acquire().await?;
send_email_with_retry(transport, to, "Subject", "Body", 3).await
}
FAQ
Bagaimana cara setup Lettre dengan tokio runtime?
Tambahkan fitur tokio1-native-tls atau tokio1-openssl di Cargo.toml. Lalu gunakan AsyncSmtpTransport<Tokio1Executor> sebagai transport type. Pastikan runtime tokio sudah diinisialisasi dengan #[tokio::main].
Apa bedanya AsyncSmtpTransport dan SmtpTransport di Lettre?
SmtpTransport adalah versi synchronous yang memblokir thread saat menunggu response. AsyncSmtpTransport berjalan di async runtime dan tidak memblokir thread. Untuk aplikasi backend modern, AsyncSmtpTransport adalah pilihan yang benar.
Bagaimana handle error ketika koneksi SMTP timeout atau di-refuse?
Klasifikasikan error berdasarkan jenisnya: connection timeout adalah temporary failure yang bisa di-retry, authentication failure adalah permanent failure yang tidak akan pernah berhasil dengan credential yang sama. Implementasikan retry logic dengan exponential backoff di application layer.
Apakah Lettre mendukung connection pool?
Ya, ketika Anda enable fitur pool (yang sudah aktif secara default di AsyncSmtpTransport), koneksi SMTP dikelola sebagai connection pool. Koneksi akan di-reuse untuk beberapa email, tidak perlu establish koneksi baru setiap kali.
Bagaimana implementasi retry logic untuk email yang gagal terkirim?
Implementasikan manual di application layer karena Lettre tidak menyediakan retry bawaan. Pakai exponential backoff dengan jitter: delay pertama 2 detik, kedua 4 detik, ketiga 8 detik, dengan jitter random 0-500ms untuk hindari thundering herd.
Port mana yang sebaiknya dipakai untuk koneksi SMTP: 465 atau 587?
Port 587 menggunakan STARTTLS (upgrade koneksi plain ke TLS setelah connected). Port 465 menggunakan SMTPS (TLS langsung dari awal). Keduanya secure, tapi port 587 lebih widely supported. KIRIM.EMAIL mendukung keduanya.
Bagaimana cara test koneksi SMTP sebelum kirim email sesungguhnya?
Gunakan AsyncSmtpTransport::test_connection() untuk verify koneksi bisa dilakukan tanpa mengirim email. Ini berguna di startup sequence untuk memastikan credential dan firewall rule sudah benar sebelum aplikasi mulai menerima traffic.
Hasbi Putra adalah Head of Marketing di KIRIM.EMAIL, email delivery infrastructure untuk developer dan tim IT di Indonesia. KIRIM.EMAIL mengirim lebih dari 11 juta email per hari dengan server yang sepenuhnya berlokasi di Indonesia.
