Testler için Bir Mail Sunucusu Yazmak

Uzun zaman önce Friday Night Programmer laboratuvarında bir e-posta sunucusuna ihtiyacım oldu. Amacım, belli bir sürecin entegrasyon testleri sırasında bir e-posta sunucusuna gidip gelme aksiyonunu taklit etmekti. İhtiyacım olan entegrasyon testi, ortamda gerçek bir e-posta sunucusu olmadan da çalışabilmeliydi. Nitekim asıl odak noktam e-postaların gidip gitmemesi değildi. Sürecin bir parçası olarak ilgili e-postalar gitmiş ve sunucudan olumlu cevap almışım(ya da almamışım) gibi hareket etmek istiyordum. Pekala, bu konuda yazılmış çeşitli sahte(fake) sunucular olabilirdi ama bana hafif sıklet bir aparat gerekiyordu. Aklıma Rust ve bu tip sunucu bazlı işlemlerde öne çıkan tokio küfesi(crate) geldi. Sonuçta bir sunucu, belli bir port üzerinden gelen mesajları yakalayıp şakacıktan karşı tarafa gönderdim gibi yapsa yeterliydi. Bu yazıdaki amacım, örnek kodlar üzerinden geçerek kendime ve sizlere hatırlatıcı noktalar çıkarmak olacak. Öyleyse gelin vakit kaybetmeden başlayalım.

Mail Sunucuları Hakkında Genel Bilgi

Pekala, bir e-posta sunucusu birçok standardı bünyesinde barındırır. Gelen postalar için IMAP(Internet Message Access Protocol), POP3(Post Office Protocol 3) gibi protokoller söz konusuyken, giden posta tarafında SMTP(Simple Mail Transfer Protocol) devreye girer. En başından beri bu protokollerin baz aldığı 25, 465, 587, 2525, 110, 143, 993 vb. portlar da söz konusudur.

Bu çalışmadaki amacım gerçek anlamda kullanılabilir ve üretim(production) ortamına konabilecek bir e-posta sunucusu yazmak değil elbette. Entegrasyon testleri sırasında SMTP protokolünün temel bazı komutlarını HELO/EHLO, MAIL FROM, RCPT TO, DATA, QUIT taklit eden, gelen mesajları diske yazan ve gerektiğinde belirli senaryolarda hata da dönebilen hafif sıklet(lightweight) bir sahte(fake) sunucu işimi görüyor.(Hata dönmesi sunucudan istenen cevap alınamadığında sistemin kalan kısmının nasıl davranış göstereceğini anlamak açısından da önemli olabilir) Böylece test ortamında gerçek bir SMTP sağlayıcısına bağımlı kalmadan, e-posta gönderim akışının uçtan uca doğru çalıştığını doğrulayabiliyordum.

Tabii ki tam teşekküllü bir SMTP sunucusu yazmayı da düşünebiliriz. Bu durumda protokolün rfc5321 standardına uygun olarak tüm komutları ve hata durumlarını ele almamız gerekecektir. Bakınız RFC 5321. Bizim demomuzda çok basit birkaç komutu ele alıyoruz.

Gerekli Küfelerin Yüklenmesi

Rust projesini(executable) oluşturduktan sonra gerekli crate eklemelerini yaparak işe başlayalım. Örnek kodları yazdığım tarih itibariyle(2025 ortaları gibi) toml içeriği aşağıdaki gibiydi. Paket sürümlerinde güncellemeler olduğunu söyleyebilirim. Buna göre son sürümleri ile de çalışabilirsiniz.

[package]
name = "light_mail_server"
version = "0.1.0"
edition = "2021"

[dependencies]
env_logger = "0.11.6"
log = "0.4.25"
tokio = { version = "1.43.0", features = ["full"] }

En önemli modülümüz asenkron kabiliyetleri ile öne çıkan tokio küfesi(crate). Bunun haricinde sunucu tarafı için log bırakmak da iyi olacaktır; nitekim uygulamayı bir Docker container'ı olarak ayağa kaldırdığımızda terminal loglarından durumu inceleme şansımız da olur. log ve env_logger küfelerini bu amaçla kullanıyoruz. Bu küfeleri doğrudan toml dosyasına yazabileceğiniz gibi aşağıdaki terminal komutları ile de ekleyebiliriz.

cargo add env_logger, log
cargo add tokio -F full

İhtiyacımız Olan Bazı Sabitler ve Utility

SMTP protokolünü bir nebze taklit edeceğimiz için olaya dahil olan komutları ve sunucunun vereceği cevapları bir enum türünde toplamayı tercih ettim. OCaml genlerinden gelen zengin bir enum türü var, her fırsatta söylemek istiyorum.

use std::fmt::{Display, Formatter};

pub enum Command {
    Hello,
    Hello2,
    HelloResponse,
    From,
    To,
    Data,
    Bye,
    Ok,
    Quit,
    Unrecognized,
    EndDataReply,
    ServiceNotAvailable,
    ServerReady,
    MessageAccepted,
    ErrorSimulated,
}

impl Command {
    pub fn as_str(&self) -> &'static str {
        match self {
            Command::Hello => "HELO",
            Command::Hello2 => "EHLO",
            Command::HelloResponse => "250 Hello\r\n",
            Command::From => "MAIL FROM",
            Command::To => "RCPT TO",
            Command::Data => "DATA",
            Command::Bye => "221 Bye\r\n",
            Command::Ok => "250 OK\r\n",
            Command::Quit => "QUIT",
            Command::Unrecognized => "500 Unrecognized command\r\n",
            Command::EndDataReply => "354 End data with <CR><LF>.<CR><LF>\r\n",
            Command::ServiceNotAvailable => "421 Service Not Available\r\n",
            Command::ServerReady => "220 Mock SMTP Server Ready\r\n",
            Command::MessageAccepted => "250 Message accepted for delivery\r\n",
            Command::ErrorSimulated => "421 Error simulated for recipient.",
        }
    }
}

impl Display for Command {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

pub const BUFFER_SIZE: usize = 1024;
pub const EMAIL_DIR: &str = "temp";
pub const INVALID_MAIL: &str = "[email protected]";
pub const ENDS_WITH: &str = "\r\n.\r\n";
pub const NO_BUFF_OR_EOF:usize = 0;

Command enum yapısı hem istemciden gelmesini beklediğimiz komutları(HELO/EHLO, MAIL FROM, RCPT TO, DATA, QUIT) hem de sunucunun geri döneceği cevapları(220, 250, 354, 421, 500 gibi klasik SMTP durum kodlarını) aynı tip altında topluyor. as_str() her varyanta karşılık gelen ham metni döndürüyor, Display trait'i de bu string'i {} ile doğrudan formatlayabilmemizi sağlıyor. Böylece sunucu tarafında "hangi duruma hangi metni yazacağız" sorusunu enum üzerinden, tip güvenli(type safe) bir şekilde çözmüş oluyoruz. Kodun kalan kısmında yer alan sabitlerin kullanım amaçları ise aşağıdaki gibi.

  • BUFFER_SIZE soket okumalarında kullanılacak tampon boyutu.
  • EMAIL_DIR gelen e-postaların diske yazılacağı klasör.
  • ENDS_WITH ise SMTP protokolünde DATA komutunun bitişini işaret eden \r\n.\r\n dizisini temsil ediyor.
  • INVALID_MAIL, testlerde bilerek hataya düşürmek istediğimiz senaryolar için ayırdığımız özel bir alıcı adresi. Sunucu bu adresi gördüğünde normal akışı bozup 421 hatasını simüle ediyor.
  • NO_BUFF_OR_EOF değeri socket.read'in 0 döndüğü(yani karşı tarafın bağlantıyı kapattığı) durumu isimlendirerek kodun okunabilirliğini artırıyor.

İhtiyaç duyabileceğimiz çok basit iki fonksiyonu ise utility.rs isimli dosyada tutmayı tercih ettim.

use crate::constants::*;
use log::*;
use std::fs::OpenOptions;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;

pub fn save_email(body: &str) -> std::io::Result<()> {
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    let filename = format!("{}/email_{}.txt", EMAIL_DIR, timestamp);

    let mut file = OpenOptions::new()
        .create(true)
        .truncate(true)
        .write(true)
        .append(false)
        .open(&filename)?;

    file.write_all(body.as_bytes())?;
    info!("Email saved to {}", filename);

    Ok(())
}

pub async fn send_response(socket: &mut TcpStream, command: Command) -> tokio::io::Result<()> {
    socket
        .write_all(command.as_str().as_bytes())
        .await
        .map_err(|e| {
            error!("Failed to send response '{}': {}", command.as_str(), e);
            e
        })
}

save_email fonksiyonu, DATA bloğunda toplanan e-posta içeriğini alıp temp/email_<unix_timestamp>.txt formasyonunda isimlendirerek metin tabanlı bir dosyaya kaydediyor. Böylece testler sırasında gerçekten ne gönderilmiş sorusunun cevabını sunucuya hiç dokunmadan diskten okuyabiliriz(Tabii diske yazma yetkisine sahip olunması gerekir) send_response fonksiyonu ise asenkron tarafta tekrar tekrar ele alacağımız "soket'e bir Command cevabı yaz" işlemini tek bir yerden kontrol etmek için kullanılıyor. Hata durumunda ise log üzerinden bir error! satırı bırakıp hatayı çağırana geri döndürüyor. Bu iki fonksiyonu kullandığımız yerleri görünce kafanızda daha net canlanacaktır diye düşünüyorum.

Sunucu Tarafı

Sunucu görevini Server isimli bir struct karşılıyor. Tabii bu metodun bir TcpStream üzerinde çalışması ve geriye tokio çalışma zamanının kullanabileceği bir Result nesnesi döndürmesi gerekiyor.

use crate::constants::*;
use crate::utility::*;
use log::*;
use std::io::ErrorKind;
use tokio::io::AsyncReadExt;
use tokio::net::TcpStream;

pub struct Server {}

impl Server {
    pub async fn handle(socket: &mut TcpStream) -> tokio::io::Result<()> {
        send_response(socket, Command::ServerReady).await?;

        let mut buffer = [0; BUFFER_SIZE];
        let mut email_body = String::new();
        let mut data_mode = false;

        loop {
            match socket.read(&mut buffer).await {
                Ok(NO_BUFF_OR_EOF) => {
                    info!("Connection closed by client");
                    break;
                }
                Ok(n) => {
                    let request = String::from_utf8_lossy(&buffer[..n]);
                    info!("Received: {}", request);

                    if data_mode {
                        email_body.push_str(&request);

                        if email_body.ends_with(ENDS_WITH) {
                            let clean_body = email_body.trim_end_matches(ENDS_WITH);
                            if let Err(e) = save_email(clean_body) {
                                error!("Failed to save email: {}", e);
                            }
                            email_body.clear();

                            send_response(socket, Command::MessageAccepted).await?;
                            data_mode = false;
                        }
                    } else {
                        match request.trim() {
                            r if r.starts_with(Command::Hello.as_str())
                                || r.starts_with(Command::Hello2.as_str()) =>
                            {
                                send_response(socket, Command::HelloResponse).await?;
                            }
                            r if r.starts_with(Command::From.as_str()) => {
                                send_response(socket, Command::Ok).await?;
                            }
                            r if r.starts_with(Command::To.as_str()) => {
                                if r.contains(INVALID_MAIL) {
                                    send_response(socket, Command::ServiceNotAvailable).await?;
                                    error!("{}", Command::ErrorSimulated.as_str());
                                } else {
                                    send_response(socket, Command::Ok).await?;
                                }
                            }
                            r if r.starts_with(Command::Data.as_str()) => {
                                send_response(socket, Command::EndDataReply).await?;
                                data_mode = true;
                            }
                            r if r.starts_with(Command::Quit.as_str()) => {
                                send_response(socket, Command::Bye).await?;
                                info!("Session closed");
                                break;
                            }
                            _ => {
                                send_response(socket, Command::Unrecognized).await?;
                                error!("Unrecognized command received: {}", request);
                            }
                        }
                    }
                }
                Err(e) if e.kind() == ErrorKind::ConnectionReset => {
                    warn!("Connection reset by peer");
                    break;
                }
                Err(e) => {
                    error!("Error reading from socket: {}", e);
                    break;
                }
            }
        }

        Ok(())
    }
}

Server::handle fonksiyonunu tek bir TCP bağlantısının tüm yaşam ömrünü yöneten bir metot olarak düşünebiliriz. Bağlantı geldiği anda önce 220 Mock SMTP Server Ready cevabıyla kendini tanıtıyor(tıpkı gerçek bir SMTP sunucusunun yaptığı gibi), sonra sonsuz bir döngüde soketten veri okumaya devam ediyor. Burada iki mod arasında gidip geliyoruz. data_mode false iken gelen her satırı bilinen SMTP komutlarıyla(HELO/EHLO, MAIL FROM, RCPT TO, DATA, QUIT) eşleştirip uygun cevabı dönüyoruz. DATA komutu geldiğinde ise data_mode değerini true yapıp asıl e-posta gövdesini(body) toplamaya başlıyoruz. Gövde, protokolün bitiş işareti olan \r\n.\r\n dizisiyle karşılaşana kadar email_body içinde biriktiriliyor, karşılaşınca da save_email ile diske yazılıp 250 Message accepted for delivery cevabı dönülüyor.

RCPT TO adımında ayrıca küçük bir "hata simülasyonumuz" var. Talep, constants.rs'te tanımladığımız INVALID_MAIL adresini içeriyorsa sunucu bilerek 421 Service Not Available dönüyor. Bu, istemci tarafındaki hata yönetimi kodunu(örneğin bir SmtpException yakalama bloğunu) gerçek bir sunucuya ihtiyaç duymadan test edebilmemi sağlıyor. Bağlantı QUIT ile ya da karşı tarafın soketi kapatmasıyla(okuma 0 bayt dönünce) sonlanıyor. ConnectionReset gibi beklenmedik durumlar oluştuğundaysa loga bilgi düşürülüp döngüden çıkılıyor.

Her şeyi main fonksiyonunda topluyoruz. Tokio çalışma zamanını kullandığımız için metodumuz hem asenkron hem de [tokio::main] direktifi ile işaretlenmiş durumda.

use crate::constants::*;
use crate::server::Server;
use log::*;
use std::env;
use tokio::fs::create_dir_all;
use tokio::net::TcpListener;

mod constants;
mod server;
mod utility;

#[tokio::main]
async fn main() -> tokio::io::Result<()> {
    env::set_var("RUST_LOG", "tokio=info,light_mail_server=info");
    env_logger::init();

    create_dir_all(EMAIL_DIR).await?;

    let listener = TcpListener::bind(("0.0.0.0", 2525)).await?;
    info!("Light SMTP Server running on 0.0.0.0:2525");

    loop {
        let (mut socket, addr) = listener.accept().await?;
        info!("Connection received from {}", addr);

        tokio::spawn(async move {
            Server::handle(&mut socket)
                .await
                .expect("Failed to run server");
        });
    }
}

Uygulamanın giriş noktası olan main.rs fonksiyonu klasik bir tokio giriş noktası. Önce RUST_LOG ortam değişkenini ayarlayıp env_logger ortamını başlatıyoruz, ardından e-postaların yazılacağı EMAIL_DIR klasörünün var olduğundan emin oluyoruz(create_dir_all klasörü zaten varsa hata vermiyor). Sonrasında 0.0.0.0:2525 üzerinde bir TcpListener açılıyor(yani sunucu tüm ağ arayüzlerinden 2525 portuna gelen bağlantıları dinlemeye başlıyor). Sonsuz döngüde her yeni bağlantı kabul edildiğinde tokio::spawn ile ayrı bir asenkron görev(task) başlatılıyor ve o bağlantının tüm yaşam döngüsü Server::handle metoduna devrediliyor. Böylece birden fazla istemci aynı anda bağlanabiliyor ve her biri kendi görevinde, birbirinden bağımsız şekilde ele alınabiliyor.

İlk Testler

Dilerseniz uygulamayı bu noktada çalıştırıp en azından ayağa kalkıp kalkmadığına bakalım.

Dockerize İşlemleri

Örneğimiz hazır ve işler durumda görünüyor. Bu çalışmayı yaptığımda örneği bir Docker Container olarak ayağa kaldırıp kullanmıştım. Dockerize işlemleri için bir imaj(image) oluşturmamız gerekiyor. Bunun için uygulamanın çalışacağı ortamı ve gereksinimleri tanımlayan bir Dockerfile dosyasına ihtiyacımız var. Onu da aşağıdaki gibi yazabiliriz.

FROM rust:1.84.0 AS builder

WORKDIR /app
COPY . .
RUN cargo build --release

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY --from=builder /app/target/release/light_mail_server /app/

CMD ["./light_mail_server"]

Dockerfile, iki aşamalı(multi-stage) bir yapı kullanıyor. İlk aşamada rust:1.84.0 imajı üzerinde cargo build --release ile derleme yapılıyor. İkinci aşamada ise sadece derlenmiş binary, çok daha küçük olan debian:bookworm-slim imajına kopyalanıyor. Bu sayede son imaj, Rust derleyicisini ve ara derleme dosyalarını içermiyor. Sadece çalıştırılabilir dosyayı ve gerekli sertifikaları barındırıyor. Bir nevi imajı optimize edip çalışma zamanı maliyetini düşürmüş oluyoruz diyebilirim. İmajı oluşturmak ve tek başına çalıştırmak için aşağıdaki komutları kullanabiliriz.

# Docker imajını oluşturmak için
docker build -t light-mail-server .

# İmajı doğrudan çalıştırmak için
docker run -p 2525:2525 light-mail-server

Buna bağlı olarak işi kolaylaştırmak adına bir docker-file dosyası içerisine servis tanımını da ekleyebiliriz.

services:
  light-mail-server:
    build: .
    ports:
      - "2525:2525"

O Zaman Bir İstemci(Client) Yazalım

Artık ortamımızda Docker container olarak çalışan ve temel SMTP hizmetini taklit eden bir e-posta sunucumuz var. Peki nasıl kullanacağız? İstemci tarafını .NET tabanlı basit bir konsol uygulaması olarak tasarlayabiliriz. Aşağıdaki kod örneği gayet yeterli olacaktır.

using System.Net.Mail;

SendEmail("[email protected]", "<h1>Challenge Accepted</h1><p>This is a sample e-mail from dotnet client</p>", true);
SendEmail("[email protected]", "<h1>Error Test</h1><p>We are waiting for 421 error.</p>", true);

static void SendEmail(string to, string body, bool isHtml)
{
    try
    {
        var client = new SmtpClient("127.0.0.1", 2525)
        {
            DeliveryMethod = SmtpDeliveryMethod.Network
        };

        var message = new MailMessage("[email protected]", to, "Demonstration Subject", body)
        {
            IsBodyHtml = isHtml
        };

        Console.WriteLine($"Sending email to {to}...");
        client.Send(message);
        Console.WriteLine("Email sent successfully!\n");
    }
    catch (SmtpException ex)
    {
        Console.WriteLine($"SMTP Error: {ex.StatusCode} - {ex.Message}\n");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"General Error: {ex.Message}\n");
    }
}

İstemci taraf, System.Net.Mail.SmtpClient sınıfını kullanıyor ve 127.0.0.1:2525 adresine, yani bizim light_mail_server'ımıza bağlanacak şekilde ayarlanıyor. SendEmail fonksiyonu bilerek iki farklı senaryoyu deniyor. İlk çağrıda normal bir HTML e-postası gönderiliyor ve sunucu bunu 250 Message accepted for delivery ile onaylıyor. İkinci çağrıda ise alıcı olarak sunucu tarafında özel anlam taşıyan [email protected] adresi veriliyor. Bu adres, RCPT TO aşamasında sunucuyu bilerek 421 Service Not Available dönmeye zorluyor ve istemci tarafındaki catch(SmtpException ex) bloğunun(yani test etmek isteyeceğimiz hata yönetimi kodunun) gerçekten çalıştığını, hakiki bir SMTP sağlayıcısına ihtiyaç duymadan doğrulamış oluyoruz.

Öyleyse taze taze bir deneme yapalım. Önce docker container'ı başlatalım ve istemci uygulamayı çalıştıralım. Ben Windows sistemimde aşağıdaki ekran görüntüsünde yer alan sonuçlara ulaştım.

O zamanlar benim için keyifli bir çalışma olmuştu. Yaklaşık bir sene sonrasında şöyle bir geriye dönüp bakınca yazı haline getirmenin iyi olacağına karar verdim. Umarım sizler için de faydalı bir pratik olur. Böylece geldik bir maceramızın daha sonuna. Tekrardan görüşünceye dek hepinize mutlu günler dilerim.

Github reposunda rust program kodlarına ulaşabilirsiniz.

Yorum ekle

Loading