Mert Özen Her Satırda Daha İleri
Backend .NET'te E-posta, PDF, Excel ve Dosya İşleri — #02

.NET'te E-posta Göndermek: MailKit ile SMTP, TLS ve Port'lar, Mailpit ile Local Test ve SmtpClient'ın Durumu

Mert Özen 15 Kas 2026 12 dk 4 görüntülenme
.NET'te E-posta Göndermek: MailKit ile SMTP, TLS ve Port'lar, Mailpit ile Local Test ve SmtpClient'ın Durumu

Yeni .NET projelerinde e-postayı System.Net.Mail.SmtpClient ile değil MailKit ile gönder: 587'de StartTls, 465'te SslOnConnect kullan, Auto'dan kaçın, bağlantıyı yeniden kullan ve parolayı secret'tan al. Bookstore'un sipariş onay e-postasını HTML, düz metin, inline logo ve ekli faturayla STARTTLS'li bir Mailpit'e gönderiyor, MIME yapısını ve 50 e-postada altı kata varan bağlantı farkını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, MailKit 5.0.0 ve Mailpit 1.33.1 ile derlenip denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) sipariş onaylandığında müşteriye e-posta gidiyor. SMTP (e-postayı sunucudan sunucuya taşıyan protokol) sunucumuz 587 numaralı port'u dinliyor ve STARTTLS istiyor. Üç soru:

Birincisi: MailKit'e "bu port'a bağlanır bağlanmaz TLS (bağlantıyı şifreleyen protokol) başlat" dersen (SecureSocketOptions.SslOnConnect) ne olur?

İkincisi: aynı sunucuya hiç şifreleme olmadan (SecureSocketOptions.None) bağlanıp kullanıcı adı ve parolayla giriş yapmayı denersen sunucu seni reddeder mi?

Üçüncüsü: 50 e-postayı her biri için yeni bağlantı açarak göndermek ile tek bağlantıdan göndermek arasında ne kadar fark vardır?

Bu yazıdaki local test sunucusunda gerçek cevaplar şöyle: ilki SslHandshakeException. İkincisi seni şaşırtabilir: test sunucumuz parolayı şifresiz bağlantıda da kabul etti, yani parolanın açık metin gitmesini sunucu engellemedi. Üçüncüsünde 692 ile 112 milisaniye arası, yaklaşık altı kat. Gerçek bir e-posta sağlayıcısında ağ gecikmesi eklendiğinde fark çok daha büyük olur.

System.Net.Mail.SmtpClient Neden Önerilmiyor?

.NET'in içinde bir SmtpClient var ve hâlâ çalışıyor. Ama Microsoft'un kendi API dokümanı onu yeni projeler için önermiyor: "We don't recommend that you use the SmtpClient class for new development because SmtpClient doesn't support many modern protocols." (Microsoft Learn, SmtpClient Class). Aynı not, yerine MailKit'i ya da başka kütüphaneleri gösteriyor. Class silinmedi; .NET Standard 2.0'ın parçası olduğu için her .NET sürümünde bulunmak zorunda. platform-compat deposundaki DE0005 (uyumluluk uyarılarına verilen kod) notu onu yalnız uyumluluk için tutulan, tek tük e-posta atan araçlara uygun bir class olarak tarif ediyor. Somut bir eksik: EnableSsl yalnız STARTTLS anlamına geliyor; bağlantının en baştan TLS ile açıldığı 465 port'u, EnableSsl dokümanına göre desteklenmiyor.

Eski bir codebase'de ona rastlayacaksın. Okurken şu karşılıklar işini görür:

System.Net.MailMailKit / MimeKit
MailMessage, MailAddressMimeMessage, MailboxAddress
AlternateViews, LinkedResourceBodyBuilder.TextBody, HtmlBody, LinkedResources
EnableSsl = trueSecureSocketOptions.StartTls (465 için SslOnConnect)
Credentials = new NetworkCredential(...)AuthenticateAsync(user, password)
SendMailAsync(message)ConnectAsync, AuthenticateAsync, SendAsync, DisconnectAsync
DeliveryMethod = SpecifiedPickupDirectoryMimeMessage.WriteToAsync(stream) ile dosyaya yazmak

MailKit 5.0 10 Kasım 2026'da çıktı. 4.x'ten gelen kodda en çok göze batacak değişiklik, SendAsync'in artık string değil SendResult dönmesi; sunucunun yanıt metni SendResult.ResponseText'te. Paket artık BouncyCastle'a bağımlı değil; DKIM (e-postayı gönderen domain adına dijital olarak imzalama) ya da S/MIME kullanıyorsan MimeKit.Cryptography paketini ayrıca eklemen gerekiyor.

MimeMessage ve BodyBuilder: HTML, Düz Metin, Ek ve Inline Görsel

MimeKit e-postayı MIME (gövdeyi, ekleri ve görselleri parçalara ayıran e-posta formatı) yapısıyla temsil ediyor. Bir önceki yazıdaki EmailMessage abstraction'ı ise MailKit'ten habersiz. MimeKit tipine çeviren tek bir factory var:

public static MimeMessage Create(EmailMessage message, SmtpOptions options)
{
    MimeMessage mime = new();
    mime.From.Add(new MailboxAddress(options.FromName, options.FromAddress));
    mime.To.Add(new MailboxAddress(message.To.DisplayName ?? "", message.To.Address));
    mime.Subject = message.Subject;
    mime.Date = DateTimeOffset.UtcNow;
    mime.MessageId = MimeUtils.GenerateMessageId(MailboxAddress.Parse(options.FromAddress).Domain);

    BodyBuilder body = new()
    {
        HtmlBody = message.HtmlBody,
        TextBody = message.TextBody,
    };

    foreach (EmailAttachment attachment in message.Attachments)
    {
        ContentType contentType = ContentType.Parse(attachment.ContentType);
        if (attachment.ContentId is null)
        {
            body.Attachments.Add(attachment.FileName, attachment.Content.ToArray(), contentType);
        }
        else
        {
            // Inline görsel: multipart/related içinde durur, HTML'den cid: ile çağrılır
            MimeEntity inline = body.LinkedResources.Add(attachment.FileName, attachment.Content.ToArray(), contentType);
            inline.ContentId = attachment.ContentId;
        }
    }

    mime.Body = body.ToMessageBody();
    return mime;
}

EmailAttachment'a tek bir alan ekledik: ContentId. Doluysa ek, HTML içinde <img src="cid:bookstore-logo"> ile gösterilen bir inline görsel oluyor. BodyBuilder hangi multipart yapısının gerektiğine kendisi karar veriyor. Sipariş onay e-postası (HTML, düz metin, inline logo ve ekli fatura) gönderildikten sonra ham mesajdaki Content-Type satırları:

Content-Type: multipart/mixed; boundary="=-qEaf4eu2L/46aDVSA0a17Q=="
Content-Type: multipart/alternative; boundary="=-fsgGF5QpNO00lDLMGeV/LQ=="
Content-Type: text/plain; charset=utf-8
Content-Type: multipart/related; boundary="=-NfcbvNfC1sS/5ULmAnrLAw==";
Content-Type: text/html; charset=utf-8
Content-Type: image/png; name=logo.png
Content-Type: text/plain; charset=utf-8; name=invoice-BKS-2026-001042.txt

En dışta multipart/mixed gövde ile eki taşıyor. İçinde multipart/alternative aynı içeriğin iki hâlini sunuyor: e-posta istemcisi gösterebildiği en zengin hâli seçiyor. HTML hâli logo ile birlikte multipart/related içinde. Düz metin gövdeyi atlama: HTML gösteremeyen istemciler, ekran okuyucular ve bazı spam filtreleri ona bakıyor.

Date header'ını UTC (saat dilimi farkı olmayan evrensel saat) olarak kendimiz veriyoruz. Vermeseydik MimeKit sunucunun local saat dilimini kullanırdı ve aynı e-posta hangi sunucudan gittiğine göre farklı bir offset taşırdı. Message-Id'yi de gönderen domain'iyle üretiyoruz; üçüncü yazıda aynı siparişe iki e-posta gitmesini önlerken bu değere ihtiyacımız olacak.

Port'lar ve TLS: 587, 465 ve SecureSocketOptions

SMTP'de üç port karşına çıkar. Hangisini kullanacağın, bağlantının ne zaman şifreleneceğini belirliyor:

PortNe içinŞifrelemeMailKit ayarı
25Sunucudan sunucuya aktarımİsteğe bağlı STARTTLSUygulamadan kullanma; çoğu cloud sağlayıcısı engelliyor
587Uygulamanın e-posta teslim etmesi (submission)Düz bağlantı açılır, STARTTLS ile yükseltilirSecureSocketOptions.StartTls
465SubmissionBağlantı en baştan TLS (implicit TLS)SecureSocketOptions.SslOnConnect

IETF (internet standartlarını geliştiren kuruluş) tarafından yayımlanan RFC (internet standartlarını tanımlayan doküman serisi) serisindeki 8314 numaralı doküman, e-posta istemcileriyle sunucular arasındaki TLS kullanımını tarif ediyor ve submission için implicit TLS'i STARTTLS'e tercih ediyor. Sağlayıcıların çoğu ikisini de destekliyor; hangisini kullanırsan kullan ayarın port'la eşleşmesi gerekiyor. Tahmin sorusundaki denemeleri local test sunucusuna karşı küçük bir console uygulamasıyla yaptık. Sunucu yalnız STARTTLS konuşuyor ve self-signed bir sertifika kullanıyor:

$ dotnet run -- connect SslOnConnect
SslOnConnect: SslHandshakeException: An error occurred while attempting to establish an SSL or TLS connection.
...
6. You are trying to connect to a port which does not support SSL/TLS.

$ dotnet run -- connect StartTls
StartTls: SslHandshakeException: An error occurred while attempting to establish an SSL or TLS connection.

The server's SSL certificate could not be validated for the following reasons:
• The server certificate has the following errors:
  • The certificate was not trusted.

$ dotnet run -- connect None --auth
None: connected, secure=False, None, auth: LOGIN PLAIN
None: authenticated as bookstore

İlk hata, düz metin konuşan bir port'ta TLS başlatmaya çalışmanın sonucu. MailKit'in hata mesajı olası nedenleri sıralıyor ve altıncısı tam olarak bu. İkincisinde port doğru ama sertifika güvenilir değil. Üçüncüsü asıl tehlikeli olan: bağlantı şifresiz ve parola açık metin gitti. Mailpit bu ayarla buna izin veriyor; gerçek sağlayıcıların çoğu reddeder, ama buna güvenme. Parolanın açık gitmemesini sağlamak istemcinin işi.

Aynı nedenle SecureSocketOptions.Auto ve StartTlsWhenAvailable'dan uzak dur. MailKit'in dokümanına göre ikisi de sunucu STARTTLS sunmuyorsa bağlantıya şifresiz devam ediyor. Araya giren biri sunucunun STARTTLS desteğini yanıttan silerse uygulaman bunu fark etmeden parolayı açık metin gönderir. StartTls ise STARTTLS yoksa hata veriyor; istediğimiz davranış bu.

Mailpit ile Local SMTP

Geliştirmede gerçek bir sağlayıcıya e-posta göndermek istemezsin: yanlışlıkla gerçek bir müşteriye e-posta gidebilir ve sağlayıcının gönderim kotasını harcarsın. Mailpit, gelen her e-postayı kabul edip saklayan, web arayüzü ve REST (HTTP üzerinden kaynak odaklı API stili) API'si olan küçük bir SMTP sunucusu. Integration ve E2E (uçtan uca) Test serisinin dış bağımlılıkları taklit etmeyi anlatan yazısında sahte SMTP sunucusu olarak kullanıldı. Bu yazıda onu production'a benzetiyoruz: STARTTLS ve kullanıcı adı/parola ile giriş açık.

$ openssl req -x509 -newkey rsa:2048 -nodes -keyout certs/key.pem -out certs/cert.pem -days 30 \
    -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
$ docker run -d --name bookstore-mailpit \
    -p 127.0.0.1:52561:1025 -p 127.0.0.1:52562:8025 \
    -v "$PWD/certs:/certs:ro" \
    -e MP_SMTP_TLS_CERT=/certs/cert.pem -e MP_SMTP_TLS_KEY=/certs/key.pem \
    -e MP_SMTP_AUTH="bookstore:<password>" \
    axllent/mailpit:v1.33.1

SMTP 52561'de, web arayüzü ve API 52562'de; port numaralarını makinedeki başka servislerle çakışmasın diye seçtik ve ikisini de yalnız 127.0.0.1'e bağladık. Sertifika self-signed olduğu için MailKit ona güvenmiyor. Çözüm sertifika doğrulamasını kapatmak değil. SmtpOptions.TrustedCertificateSha256 yalnız geliştirmede dolu oluyor ve sender bu durumda yalnız SHA (bir hash algoritması ailesi) ailesinden SHA-256 ile hesaplanan parmak izi tam olarak eşleşen sertifikayı kabul ediyor:

if (_options.TrustedCertificateSha256 is { } pinned)
{
    client.ServerCertificateValidationCallback = (_, certificate, _, errors) =>
        errors == SslPolicyErrors.None || IsPinned(certificate, pinned);
    client.CheckCertificateRevocation = false;
}

Gönderilen sipariş onay e-postası Mailpit arayüzünde böyle görünüyor:

02-mailpit-order-confirmation-tr.png

Sağ üstte bir ek ve bir inline görsel sayılmış, logo HTML içinde cid: ile yerine oturmuş. "HTML Check" sekmesi HTML'in e-posta istemcilerinde ne kadar desteklendiğini puanlıyor; üçüncü yazıda şablonlarla uğraşırken bu sekmeye döneceğiz.

MailKitEmailSender: Bağlantıyı Yeniden Kullanmak

Her e-posta için bağlantı açmak şu adımları her seferinde tekrarlamak demek: TCP bağlantısı, sunucunun karşılama mesajı, EHLO, STARTTLS ile TLS handshake'i, AUTH, gönderim ve QUIT. Bookstore'un sender'ı bağlantıyı bir kez açıp tutuyor:

// Tek SMTP bağlantısı mesajlar arasında yeniden kullanılır; SmtpClient thread-safe olmadığı için gönderimler sıraya girer
public sealed class MailKitEmailSender(IOptions<SmtpOptions> options, ILogger<MailKitEmailSender> logger) : IEmailSender, IAsyncDisposable
{
    private readonly SmtpOptions _options = options.Value;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private SmtpClient? _client;

    public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default)
    {
        MimeMessage mime = MimeMessageFactory.Create(message, _options);

        await _gate.WaitAsync(cancellationToken);
        try
        {
            SmtpClient client = await GetConnectedClientAsync(cancellationToken);
            SendResult result = await client.SendAsync(mime, cancellationToken);
            logger.LogInformation("Sent {MessageId} to {To}: {Response}", mime.MessageId, message.To.Address, result.ResponseText);
        }
        catch (Exception ex) when (ex is IOException or SmtpProtocolException or ServiceNotConnectedException)
        {
            // Bağlantının durumu belirsiz: bırak, bir sonraki gönderim yeniden bağlansın
            await ResetClientAsync();
            throw;
        }
        finally
        {
            _gate.Release();
        }
    }
}

GetConnectedClientAsync bağlantı yoksa ya da kopmuşsa yenisini açıp giriş yapıyor. Hata yakalandığında exception'ı yutmuyoruz: bağlantıyı bırakıp tekrar fırlatıyoruz. Gönderimin tekrar denenmesine sender değil, çağıran taraf karar verecek; bunu üçüncü yazıda outbox ile kuracağız. Sender singleton olarak kayıtlı, gönderim sırasında SmtpClient'ı korumak için bir SemaphoreSlim kullanıyor. Bu, tek bağlantı üzerinden saniyede birkaç yüz e-posta demek; daha fazlası gerekirse birkaç bağlantılık küçük bir havuz kurmak gerekir.

Local Mailpit'e 50 e-postayı üç kez gönderdik:

$ dotnet run -- timing 50
50 messages, new connection each: 704 ms
50 messages, one reused connection: 132 ms
50 messages, new connection each: 692 ms
50 messages, one reused connection: 112 ms
50 messages, new connection each: 695 ms
50 messages, one reused connection: 68 ms

Aynı makinede, ağ gecikmesi olmadan bile fark beş altı kat. Sağlayıcı başka bir veri merkezindeyse her handshake birkaç tur ağ gecikmesi ekliyor. Bazı sağlayıcılar kısa sürede çok bağlantı açan istemcileri geçici olarak engelliyor da.

SMTP Ayarları: Options ve Secret

Ayarlar SmtpOptions'a bind ediliyor ve uygulama açılırken doğrulanıyor:

services.AddOptions<SmtpOptions>()
    .Bind(smtp)
    .ValidateDataAnnotations()
    .Validate(o => o.UserName is null || !string.IsNullOrEmpty(o.Password), "Smtp:Password is required when Smtp:UserName is set.")
    .ValidateOnStart();
services.AddSingleton<IEmailSender, MailKitEmailSender>();

appsettings.json'da parola yok:

"Smtp": {
  "Host": "127.0.0.1",
  "Port": 52561,
  "Security": "StartTls",
  "UserName": "bookstore",
  "FromAddress": "orders@example.com",
  "FromName": "Bookstore"
}

Parola environment variable ile geliyor (Windows PowerShell'de $env:Notifications__Smtp__Password = "<password>"). Production'da aynı değer Key Vault gibi bir secret store'dan okunuyor; Configuration ve Secret Management serisi bu katmanları ayrıntılı anlatıyor. ValidateOnStart sayesinde parolası eksik bir deploy, ilk sipariş e-postasında değil uygulama açılırken hata veriyor. Notifications:Smtp:Host hiç yoksa (repo'yu yeni klonlamış biri gibi) AddNotifications bir önceki yazıdaki LoggingEmailSender'a dönüyor.

Mailpit ile Integration Test

Unit testlerde IEmailSender'ın sahtesi yeterli. Ama MIME yapısının, inline görselin ve ekin gerçekten doğru çıktığını ancak gerçek bir SMTP konuşmasından sonra görebilirsin. Mailpit'in REST API'si bunu test edilebilir kılıyor:

await using (MailKitEmailSender sender = new(Options.Create(options), NullLogger<MailKitEmailSender>.Instance))
{
    await sender.SendAsync(message, ct);
}

using HttpClient mailpit = new() { BaseAddress = new Uri(api!) };
MailpitSearch? found = await mailpit.GetFromJsonAsync<MailpitSearch>(
    $"/api/v1/search?query={Uri.EscapeDataString($"subject:\"{message.Subject}\"")}", ct);

MailpitSummary summary = Assert.Single(found!.Messages);
Assert.Equal("ada@example.com", Assert.Single(summary.To).Address);

MailpitMessage? full = await mailpit.GetFromJsonAsync<MailpitMessage>($"/api/v1/message/{summary.ID}", ct);
Assert.Equal("bookstore-logo", Assert.Single(full!.Inline).ContentID);
Assert.Equal("invoice.txt", Assert.Single(full.Attachments).FileName);
Assert.Contains("cid:bookstore-logo", full.HTML);
Assert.Contains("Total incl. VAT: ₺1.702,00", full.Text);

Konu satırına bir Guid ekleyerek testin yalnız kendi e-postasını bulmasını sağlıyoruz; aynı Mailpit'i başka testler de kullanabilir. MAILPIT_API_URL tanımlı değilse test xUnit v3'ün Assert.SkipWhen'i ile atlanıyor, böylece Mailpit'i olmayan bir makinede de dotnet test yeşil kalıyor:

$ dotnet test
Test run summary: Passed!
  total: 5
  failed: 0
  succeeded: 5
  skipped: 0

CI'da Mailpit'i bir service container olarak başlatıp bu değişkeni vermek yeterli.

Üretimde Dikkat

Gönderen domain'in SPF (domain adına hangi sunucuların e-posta gönderebileceğini söyleyen kayıt), DKIM ve DMARC (SPF ya da DKIM kontrolü başarısız olursa alıcının ne yapacağını söyleyen politika) kayıtları doğru değilse e-postaların spam klasörüne düşer ya da hiç teslim edilmez. Bu kayıtların kurulumu Domain, DNS ve CDN (içerik dağıtım ağı) serisinde anlatıldı. Uygulama tarafında yapman gereken, From adresini bu kayıtların kapsadığı domain'den seçmek.

SMTP sunucusunun 250 Ok yanıtı e-postanın teslim edildiği anlamına gelmiyor, yalnız sağlayıcının kuyruğuna girdiğini söylüyor. Bounce'lar (teslim edilemeyen e-postanın geri dönmesi) ve şikâyetler sonradan gelir. Sağlayıcıların bunları webhook ile bildirmesi External Service Integration ve Resilience serisindeki webhook yazısının konusu.

Timeout koy. SmtpOptions.Timeout varsayılan olarak 30 saniye ve SmtpClient.Timeout'a geçiyor. Yanıt vermeyen bir sunucu, timeout yoksa job worker'ını uzun süre meşgul eder.

Sağlayıcının gönderim limitlerini öğren: saatte ya da günde kaç e-posta, bağlantı başına kaç mesaj. Toplu gönderimlerde bu limitlere takılırsın ve sağlayıcı 4xx (geçici) yanıt döner. Bu yanıtları ayırt edip daha sonra tekrar denemek üçüncü yazının konusu.

Log'a e-posta gövdesini yazma. Message-Id, alıcı ve sağlayıcının yanıtı sorun takibine yeter; gövde kişisel veri içerir.

Sık Düşülen Tuzaklar

Her e-posta için yeni bağlantı açmak. Az sayıda e-postada fark edilmez; toplu gönderimde hem yavaşlatır hem de sağlayıcının hız limitine takılırsın.

SMTP parolasını appsettings.json'a yazmak. O dosya repo'ya girer ve parola commit geçmişinde kalır. Parola environment variable'dan ya da bir secret store'dan gelmeli.

Gönderim hatasını yutmak. catch { } ile geçilen bir SmtpCommandException, müşterinin onay e-postasının hiç gitmediği anlamına gelir ve bunu kimse öğrenmez. Hatayı log'la ve fırlat ya da tekrar denenecek şekilde kaydet.

Sertifika doğrulamasını (s, c, h, e) => true ile kapatmak. Geliştirmede çalışır, sonra aynı satır production'a gider. Self-signed bir test sertifikası gerekiyorsa parmak iziyle sabitle ve bunu yalnız geliştirme ayarına koy.

Port ile şifreleme ayarını karıştırmak. 587'de SslOnConnect, 465'te StartTls handshake hatası verir; Auto ise en kötü durumda sessizce şifresiz devam eder.

Küçük Bir Deneme

★☆☆ Mailpit'i -e MP_SMTP_REQUIRE_TLS=true ile yeniden başlat. connect StartTls ve connect SslOnConnect denemelerinin sonucu nasıl değişiyor? appsettings.json'da hangi ayarı değiştirmen gerekiyor?

★★☆ OrderConfirmationEmail'deki düz metin gövdeyi boş bırak ve e-postayı tekrar gönder. Ham mesajda multipart/alternative kalıyor mu? Mailpit'in "Text" sekmesinde ne görüyorsun?

★★★ MailKitEmailSender'ı iki bağlantılık küçük bir havuza dönüştür ve timing 200 ile tek bağlantıyla karşılaştır. Mailpit'in MP_SMTP_MAX_RECIPIENTS gibi limit ayarlarına bakarak bir sağlayıcının bağlantı başına sınırını nasıl taklit edebileceğini düşün.

Sipariş onay e-postasının HTML'ini şimdilik string birleştirerek kurduk. Bir sonraki yazıda e-posta HTML'ini Razor component'leriyle ve TR/EN şablonlarla üretiyor, e-posta istemcilerinin CSS kısıtlarına bakıyor ve gönderimi outbox ile retry'a bağlıyoruz.