E-posta Şablonları ve Güvenilir Gönderim: Razor HtmlRenderer, Inline CSS, Outbox, Retry ve Sağlayıcı API'leri
E-posta HTML'ini Razor component'i olarak yazıp HtmlRenderer ile string'e render et, CSS'i PreMailer.Net ile inline et, e-postayı iş verisiyle aynı transaction'da bir outbox satırına yaz ve 4xx'te takvimle tekrar dene, 5xx'te bırak. Bookstore'da onaylayıcıya giden TR/EN hatırlatma, Mailpit chaos'unun iki 451'inden sonra aynı Message-Id ile bir kez gidiyor; idempotency key ikinci çalıştırmayı engelliyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, MailKit 5.0.0, PreMailer.Net 2.7.4, Entity Framework Core 10 (SQLite) ve Mailpit 1.33.1 ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) kurumsal müşterilerin siparişleri bir onaylayıcıdan geçiyor. Okulun satın alma sorumlusu Nevin Hanım iki siparişi 48 saatten uzun süredir onaylamadı. Her sabah çalışan bir job ona hatırlatma e-postası gönderiyor. Üç soru:
Birincisi: job bir gün içinde yanlışlıkla iki kez çalıştı. Nevin Hanım kaç e-posta alır?
İkincisi: SMTP (e-posta gönderme protokolü) sunucusu ilk iki denemede 451 (geçici hata) döndü. E-posta kaybolur mu, iki kez mi gider, yoksa üçüncü denemede bir kez mi?
Üçüncüsü: aynı şablonun İngilizcesi, tek siparişi bekleyen bir onaylayıcıya gidiyor. Konu satırı ne olur?
Bu yazının sonundaki kodla cevaplar: bir e-posta; ikinci çalıştırma {"queued":0,"alreadyQueued":2} dönüyor. İkincisinde e-posta üçüncü denemede, aynı Message-Id ile bir kez gidiyor. Üçüncüsü ise ilk denemede bir hataydı: "1 purchase approvals are waiting for you". Hepsinin nedenini sırayla göreceğiz.
Razor Component'iyle E-posta Şablonu
Bir önceki yazıda sipariş onay e-postasının HTML'ini string birleştirerek kurduk. Birkaç satırlık e-postada idare eder, ama tablo, koşullu satırlar ve iki dil devreye girince okunmaz hâle gelir ve her değişkeni elle encode etmeyi unutmamak gerekir. .NET 8'den beri Razor component'lerini bir HTTP request'i olmadan, HtmlRenderer ile doğrudan string'e render edebiliyorsun. Bunun için class library'nin SDK'sı Microsoft.NET.Sdk.Razor oluyor ve Microsoft.AspNetCore.Components.Web paketi ekleniyor.
Hatırlatma şablonu bir layout component'i içine yazılıyor:
@inject IStringLocalizer<EmailText> T
<EmailLayout Title="@(One ? T["ReminderSubjectOne"] : T["ReminderSubject", Model.Pending.Count])" Footer="@T["Footer"]">
<p>@T["Greeting", Model.ApproverName]</p>
<p>@(One ? T["ReminderIntroOne", Model.InstitutionName] : T["ReminderIntro", Model.InstitutionName, Model.Pending.Count])</p>
<table class="items" role="presentation">
@foreach (PendingApproval item in Model.Pending)
{
<tr>
<td>#@item.OrderId</td>
<td>@item.RequestedBy (@item.UnitName)</td>
<td>@item.RequestedAtUtc.ToString("d MMMM yyyy")</td>
<td class="amount">@item.Total.ToString("C", Money)</td>
</tr>
@if (item.Note is not null)
{
<tr><td colspan="4">@T["Note"]: @item.Note</td></tr>
}
}
</table>
<p><a class="button" href="@Model.ApprovalsUrl">@T["ReviewButton"]</a></p>
</EmailLayout>
@code {
private static readonly CultureInfo Money = CultureInfo.GetCultureInfo("tr-TR");
[Parameter, EditorRequired] public required ApprovalReminderModel Model { get; set; }
private bool One => Model.Pending.Count == 1;
}
EmailLayout HTML iskeletini, bir <style> bloğunu, başlığı ve footer'ı taşıyor; her e-posta şablonu aynı çerçeveyi kullanıyor. Metinler EmailText.resx (İngilizce) ve EmailText.tr.resx dosyalarında; component onları ASP.NET Core'un IStringLocalizer'ı ile okuyor. Tutar her dilde TRY (Türk lirası) olduğu için para formatını sabit tr-TR ile, tarihi ise onaylayıcının kültürüyle yazıyoruz.
Render eden service:
public async Task<EmailMessage> ApprovalReminderAsync(ApprovalReminderModel model, EmailAddress to, CultureInfo culture)
{
// Burada set edilen kültür renderer'ın dispatcher'ına akar; method dönünce eski değerine döner
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
string html = await RenderAsync<PurchaseApprovalReminder>(new Dictionary<string, object?> { ["Model"] = model });
return new EmailMessage
{
To = to,
Subject = model.Pending.Count == 1 ? text["ReminderSubjectOne"] : text["ReminderSubject", model.Pending.Count],
HtmlBody = html,
TextBody = HtmlToText(html),
};
}
private async Task<string> RenderAsync<TComponent>(Dictionary<string, object?> parameters) where TComponent : IComponent
{
await using HtmlRenderer renderer = new(services, loggerFactory);
string html = await renderer.Dispatcher.InvokeAsync(async () =>
{
HtmlRootComponent output = await renderer.RenderComponentAsync<TComponent>(ParameterView.FromDictionary(parameters));
return output.ToHtmlString();
});
// ... CSS inline edilir (aşağıda)
}
Microsoft'un dokümanına göre RenderComponentAsync çağrısı renderer'ın dispatcher'ı üzerinden yapılmak zorunda; InvokeAsync bunu sağlıyor. Kültürü async bir method içinde değiştirdiğimiz için değişiklik çağıranın kültürünü etkilemiyor; .NET async akışta kültürü execution context ile taşıyor ve method bitince eski değer geri geliyor. Düz metin gövdeyi aynı HTML'den AngleSharp ile türetiyoruz, böylece iki gövde birbirinden kopmuyor.
Üçüncü tahmin sorusunun hikâyesi burada. Şablonun ilk hâlinde tek bir ReminderSubject metni vardı ve "{0} purchase approvals are waiting for you" diyordu. Türkçesi her sayıda doğru ("1 satın alma onayı"), İngilizcesi ise tek siparişte Mailpit'in gelen kutusunda "1 purchase approvals are waiting for you" olarak göründü. Çözüm, tekil hâl için ayrı metin: ReminderSubjectOne. Başka dillere açılacaksan çoğul kuralları dilden dile değişir; o noktada ICU (Unicode'un uluslararasılaştırma kütüphanesi) MessageFormat'ını, yani dile göre çoğul kurallarını tanımlayan mesaj formatını destekleyen bir kütüphaneye bakmak gerekir.
E-posta İstemcilerinin CSS Kısıtları ve Inline CSS
Bir web sayfası için yazdığın CSS'in e-postada aynen çalışmasını bekleme. Mailpit'in HTML Check sekmesi, gönderilen HTML'deki her element ve CSS property'si için hangi e-posta istemcilerinin onu desteklediğini sayıyor. <style> bloklu ilk hâlini gönderdiğimizde <style> elementi için sonuç şuydu:
$ curl -s http://127.0.0.1:52562/api/v1/message/{id}/html-check | jq '.Warnings[] | select(.Slug=="html-style") | .Score'
{
"Found": 1,
"Supported": 61.11111,
"Partial": 18.88889,
"Unsupported": 20
}
İstemcilerin beşte biri <style> bloğunu hiç desteklemiyor, bir kısmı da kısmen destekliyor; listede Gmail'in bazı mobil ve webmail sürümleri var. Bu yüzden e-posta HTML'inde yerleşik yöntem stilleri her elementin style attribute'una taşımak. Bunu elle yapmak yerine render'dan sonra PreMailer.Net (MIT lisanslı) ile yapıyoruz:
// Birçok e-posta istemcisi <style>'ı yok sayar; her kuralı style="" attribute'larına kopyala
InlineResult inlined = PreMailer.Net.PreMailer.MoveCssInline(html, removeStyleElements: true);
foreach (string warning in inlined.Warnings)
{
logger.LogWarning("CSS inliner: {Warning}", warning);
}
return inlined.Html;
Şablonu yazarken <style> içinde class'larla rahat çalışıyorsun, gönderilen HTML'de ise her hücre kendi stilini taşıyor:
<td class="amount" style="text-align: right;white-space: nowrap;border-bottom: 1px solid #e2e8f0;padding: 6px 4px">₺9.120,00</td>
Inliner CSS specificity kurallarına uyuyor ve bu bizi bir kez yakaladı: ilk denemede .amount { text-align: right } kuralı başlık hücresinde .items th { text-align: left } kuralına yenildi, çünkü .items th daha spesifik. Tarayıcı da aynı sonucu verirdi; kuralı .items .amount yapınca düzeldi. Layout'u <div> ve flexbox yerine <table role="presentation"> ile kurmamızın nedeni de aynı: e-posta istemcilerinin bir kısmı modern layout özelliklerini desteklemiyor. Mailpit'in genel puanı inline edilmiş şablon için yüzde 88:

Kullanıcı Girdisini Encode Etmek
Siparişi talep eden öğretmenin girdiği not şablona giriyor. Notu bilerek kötü niyetli yazdık: <b>Urgent</b> for the reading week <script>alert('x')</script>. Razor @item.Note ifadesini varsayılan olarak HTML-encode ediyor ve gönderilen HTML'de satır şöyle:
<td colspan="4" style="border-bottom: 1px solid #e2e8f0;padding: 6px 4px">Not: <b>Urgent</b> for the reading week <script>alert('x')</script></td>
Ekranda not, etiketleriyle birlikte düz metin olarak görünüyor. E-posta istemcilerinin çoğu script çalıştırmıyor, ama encode edilmemiş HTML ile bir saldırgan e-postaya sahte bir "şifreni yenile" linki ya da senin markanla bir form koyabilir. Bu e-posta Bookstore'un adresinden ve onun DKIM (e-postanın domain adına dijital imzası) imzasıyla gittiği için alıcının şüphelenmesi için bir neden yok. Razor'da encode'u kapatmanın yolu (MarkupString) ile cast etmek; şablonlarda bunu yalnız senin ürettiğin ve kullanıcı girdisi içermeyen HTML için kullan. String birleştirerek HTML kuruyorsan her değişkende WebUtility.HtmlEncode çağırmayı unutmamak sana kalıyor; bir önceki yazıdaki OrderConfirmationEmail bunu elle yapıyordu.
Gönderimi Transaction'dan Ayırmak: Outbox
Hatırlatma job'unu en basit hâliyle şöyle yazabilirdin: onay kayıtlarını oku, e-postayı gönder, LastRemindedAtUtc'yi güncelle, SaveChanges. Burada iki ayrı sistem var (veritabanı ve SMTP) ve ikisini tek transaction'a koyamazsın. E-posta gittikten sonra SaveChanges patlarsa veritabanı hatırlatmayı göndermediğini sanır ve ertesi çalıştırmada tekrar gönderir. Sıralamayı ters çevirip önce kaydedersen bu sefer de SMTP hatasında kayıt "gönderildi" der ama e-posta hiç gitmemiştir.
Message Queue'larla Çalışmak serisinin transactional outbox yazısındaki çözümü uyguluyoruz: e-postayı göndermek yerine, iş verisiyle aynı DbContext'e bir outbox satırı olarak ekliyoruz. İkisi ya birlikte commit olur ya hiç olmaz. Bookstore'un asıl veritabanı SQL Server; örnek kendi içinde çalışsın diye burada SQLite kullanıyoruz.
// E-postayı çağıranın DbContext'ine ekler; yalnız çağıranın SaveChanges'i commit olursa gönderilir
public sealed class EmailOutbox(TimeProvider time, IOptions<EmailOutboxOptions> options)
{
public async Task<bool> EnqueueAsync(DbContext db, string idempotencyKey, EmailMessage message, CancellationToken cancellationToken = default)
{
if (await db.Set<OutboxEmail>().AnyAsync(e => e.IdempotencyKey == idempotencyKey, cancellationToken))
{
return false;
}
db.Set<OutboxEmail>().Add(OutboxEmail.From(idempotencyKey, message, options.Value.SenderDomain, time.GetUtcNow().UtcDateTime));
return true;
}
}
Hatırlatma job'unun sonu:
string key = $"approval-reminder:{first.ApproverEmail}:{now:yyyy-MM-dd}";
if (await outbox.EnqueueAsync(db, key, message, cancellationToken))
{
queued++;
foreach (PurchaseApproval approval in approver)
{
approval.LastRemindedAtUtc = now;
}
}
else
{
alreadyQueued++;
}
// ...
// Outbox satırları ve LastRemindedAtUtc birlikte commit olur ya da hiçbiri olmaz
await db.SaveChangesAsync(cancellationToken);
OutboxEmailDispatcher bir BackgroundService; birkaç saniyede bir zamanı gelmiş satırları okuyup IEmailSender ile gönderiyor ve her e-postadan sonra durumu kaydediyor.
Retry Takvimi ve Kalıcı Hata
Her SMTP hatası tekrar denemeye değmez. 4xx yanıtlar geçici: sunucu meşgul, alıcının kutusu geçici olarak erişilemez, hız limitine takıldın. 5xx yanıtlar kalıcı: adres yok, içerik reddedildi. Tekrar denemek yalnız sağlayıcıdaki itibarını düşürür. MailKitEmailSender bu ayrımı kendi exception'ına çeviriyor; outbox MailKit tiplerini tanımıyor:
catch (SmtpCommandException ex)
{
// 4xx: sonra tekrar dene; 5xx: sunucu bu mesajı hiçbir zaman kabul etmeyecek
bool transient = (int)ex.StatusCode is >= 400 and < 500;
throw new EmailDeliveryException($"SMTP {(int)ex.StatusCode} {ex.ErrorCode}: {ex.Message}", transient, ex);
}
Dispatcher geçici hatada bir sonraki denemeyi takvime göre erteliyor, kalıcı hatada ya da takvim bitince satırı Failed yapıyor:
private void Reschedule(OutboxEmail email, EmailDeliveryException ex)
{
TimeSpan[] delays = options.Value.RetryDelays;
email.LastError = ex.Message;
if (!ex.IsTransient || email.Attempts > delays.Length)
{
email.Status = OutboxEmailStatus.Failed;
logger.LogError("Outbox {Key} failed permanently after {Attempts} attempts: {Error}", email.IdempotencyKey, email.Attempts, ex.Message);
return;
}
email.NextAttemptAtUtc = time.GetUtcNow().UtcDateTime + delays[email.Attempts - 1];
logger.LogWarning("Outbox {Key} attempt {Attempt} failed ({Error}); next attempt at {Next:HH:mm:ss}",
email.IdempotencyKey, email.Attempts, ex.Message, email.NextAttemptAtUtc);
}
Varsayılan takvim 1 dakika, 5 dakika, 30 dakika, 2 saat ve 6 saat; yani bir e-posta yaklaşık dokuz saat boyunca altı kez denenip bırakılıyor. Geliştirme ortamında appsettings.Development.json takvimi 5, 10 ve 20 saniyeye indiriyor. Burada configuration binder'ın bir davranışı bizi yakaladı. Property'ye varsayılan bir dizi verdiğinde binder onu değiştirmiyor, configuration'daki değerleri dizinin sonuna ekliyor. Küçük bir deneme projesinde 1 ve 5 dakikalık varsayılan diziye configuration'dan 5 ve 10 saniyeyi bind ettiğimizde sonuç şuydu:
00:01:00, 00:05:00, 00:00:05, 00:00:10
Bu yüzden RetryDelays boş başlıyor ve varsayılan takvim PostConfigure içinde, configuration'da değer yoksa veriliyor.
Mailpit'in chaos özelliği SMTP hatalarını taklit edebiliyor. PUT /api/v1/chaos ile alıcı aşamasında yüzde 100 olasılıkla 451 dönmesini istedik, job'u iki kez tetikledik ve dokuz saniye sonra chaos'u kapattık:
$ curl -s -X POST http://127.0.0.1:52560/api/institutional/approval-reminders/run
{"queued":2,"alreadyQueued":0}
$ curl -s -X POST http://127.0.0.1:52560/api/institutional/approval-reminders/run
{"queued":0,"alreadyQueued":2}
warn: Bookstore.Notifications.Outbox.OutboxEmailDispatcher[0]
Outbox approval-reminder:nevin@example.com:2026-11-15 attempt 1 failed (SMTP 451 RecipientNotAccepted: Chaos recipient error); next attempt at 05:52:50
warn: Bookstore.Notifications.Outbox.OutboxEmailDispatcher[0]
Outbox approval-reminder:nevin@example.com:2026-11-15 attempt 2 failed (SMTP 451 RecipientNotAccepted: Chaos recipient error); next attempt at 05:53:01
info: Bookstore.Notifications.Email.MailKitEmailSender[0]
Sent 7037395b2256da849b3f26c647792fc7@example.com to nevin@example.com: 2.0.0 Ok: queued as 1wmLclPEhx4i2VjotX5jhA
info: Bookstore.Notifications.Outbox.OutboxEmailDispatcher[0]
Outbox approval-reminder:nevin@example.com:2026-11-15 sent on attempt 3
Kalıcı hata ve takvimin bitmesi senaryolarını Mailpit yerine unit test'te, FakeTimeProvider ile zamanı ileri sararak deniyoruz:
await dispatcher.DispatchDueAsync(ct); // 1. deneme başarısız
_time.Advance(TimeSpan.FromSeconds(59));
Assert.Equal(0, await dispatcher.DispatchDueAsync(ct)); // henüz zamanı gelmedi
_time.Advance(TimeSpan.FromSeconds(1));
await dispatcher.DispatchDueAsync(ct); // 2. deneme başarısız
_time.Advance(TimeSpan.FromMinutes(5));
await dispatcher.DispatchDueAsync(ct); // 3. deneme başarılı
$ dotnet test -- --filter-class "*OutboxEmailDispatcherTests"
Test run summary: Passed!
total: 4
failed: 0
succeeded: 4
skipped: 0
Idempotency: Aynı Kişiye İki E-posta Gitmesin
Tahmin sorusunun ilk cevabı idempotency key'den geliyor: approval-reminder:nevin@example.com:2026-11-15. Job aynı gün kaç kez çalışırsa çalışsın aynı key üretiliyor. EnqueueAsync önce key'i arıyor, tabloda da key üzerinde unique index var. Aynı anda çalışan iki job ikisi de "yok" görürse ikinci SaveChanges unique index'e takılıyor. Sipariş onayı için key order-confirmed:1042 gibi siparişe bağlı olmalı; zamana bağlı key yalnız "günde bir hatırlatma" gibi bilerek tekrarlanan e-postalarda anlamlı.
Outbox'ın dürüst bir sınırı var: at-least-once. Dispatcher e-postayı gönderdikten sonra, Sent durumunu kaydetmeden çökerse, yeniden başladığında aynı e-postayı tekrar gönderir. Bu pencere bir SaveChanges kadar kısa, ama sıfır değil; Background Job'lar serisinin retry ve idempotency yazısı bu gerçeği ayrıntılı anlatıyor. Biz zararı azaltıyoruz: Message-Id key'den türetiliyor ve her denemede aynı kalıyor:
// Aynı key -> aynı Message-Id, her retry'da da
MessageId = $"{Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(idempotencyKey)))[..32]}@{senderDomain}",
Yukarıdaki log'da üçüncü denemede giden 7037395b2256da849b3f26c647792fc7@example.com bu değer. Sağlayıcılar ve bazı alıcı sunucuları aynı Message-Id'li ikinci mesajı kopya olarak görebiliyor; en azından destek ekibi iki e-postanın aynı mesaj olduğunu header'dan anlayabiliyor.
SMTP mi, Sağlayıcının HTTP API'si mi?
Transactional e-posta sağlayıcılarının çoğu hem SMTP hem de HTTP API sunuyor; Azure Communication Services Email bunun bir örneği. API'nin SMTP'ye göre avantajları: bağlantı ve TLS (şifreli bağlantı protokolü) durumu tutmak yerine her gönderim bağımsız bir HTTPS isteği. Şablon, toplu gönderim, etiketleme ve teslim takibi API'de daha zengin. Teslim, bounce ve açılma bilgisini sağlayıcı event olarak geri gönderiyor; Azure Communication Services bunun için Event Grid'e Microsoft.Communication.EmailDeliveryReportReceived event'i yayımlıyor. Dezavantajı, kodun tek sağlayıcıya bağlanması.
IEmailSender sayesinde bu karar outbox'ı, şablonları ve job'ları etkilemiyor: sağlayıcıya geçmek, EmailDeliveryException'ı aynı geçici/kalıcı ayrımıyla dolduran yeni bir sender yazmak demek. HTTP API'de bu ayrım SMTP kodları yerine HTTP status code'larından geliyor: 429 ve 5xx geçici, 400 kalıcı. Resilience katmanı için External Service Integration ve Resilience serisinin standard pipeline yazısına bakabilirsin. Sağlayıcının bounce ve complaint webhook'larını almak aynı serinin webhook yazısında anlatıldı. Hangisini seçersen seç, SPF (domain adına kimlerin e-posta gönderebileceğini söyleyen kayıt), DKIM ve DMARC (doğrulama başarısız olursa ne yapılacağını söyleyen politika) kayıtları Domain, DNS ve CDN (içerik dağıtım ağı) serisindeki gibi kurulmuş olmalı.
Üretimde Dikkat
Birden fazla API instance'ı aynı outbox tablosunu okuyorsa iki dispatcher aynı satırı aynı anda alabilir. SQL Server'da satırları UPDLOCK, READPAST hint'leriyle sahiplenmek gerekir; Message Queue'larla Çalışmak serisinin outbox yazısı bu sorguyu gösteriyor. Tek instance'ta bu sorun yok.
Failed satırları izle. Kalıcı hataya düşen bir sipariş onayı, müşterinin e-postasını hiç almadığı anlamına gelir. Bu satırların sayısı için bir metric ve alarm kur; adresi düzeltip satırı tekrar Pending yapan bir yönetim ekranı da işe yarar.
Outbox tablosu büyür. Gönderilmiş satırları belli bir süre sonra (ör. 30 gün) silen ya da arşivleyen bir job ekle. HTML gövde her satırda tekrarlandığı için tablo sandığından hızlı büyür.
Gövdeyi kuyruğa girerken render ediyoruz, gönderirken değil. Böylece kullanıcı o anda gördüğü veriyi alıyor ve şablon hatası job içinde, iş verisiyle birlikte ortaya çıkıyor. Bedeli, outbox'ta kişisel veri tutmak; saklama süresini KVKK (Kişisel Verilerin Korunması Kanunu) ve şirket politikasına göre belirle.
Ekleri outbox'a gömme. Fatura PDF'ini IFileStore'a yaz, outbox satırında yalnız path'ini tut; serinin son yazısında bu zinciri kuruyoruz.
Sık Düşülen Tuzaklar
Transaction commit olmadan e-posta göndermek. Sipariş rollback olur, müşteri ise "siparişiniz onaylandı" e-postasını almıştır. E-postayı outbox'a yaz, gönderimi commit'ten sonraya bırak.
Kullanıcı girdisini encode etmeden şablona basmak. (MarkupString) cast'i ya da string birleştirme, e-postayı senin domain'inden giden bir phishing aracına dönüştürebilir.
Her hatada tekrar denemek. 550 alan bir adrese saatlerce göndermeye çalışmak sağlayıcıdaki itibarını düşürür; kalıcı hatayı ayır.
Retry'da yeni Message-Id üretmek. Kopya e-posta gittiğinde ikisinin aynı mesaj olduğunu anlamanın yolunu kendin kapatırsın.
Şablonu yalnız bir dilde test etmek. Bizim "1 purchase approvals" hatamız gibi, çoğul ekler ve tarih biçimleri her dilde ayrı bir test ister.
Küçük Bir Deneme
★☆☆ Mailpit'te chaos'u {"Recipient":{"ErrorCode":550,"Probability":100}} ile aç ve hatırlatma job'unu tekrar çalıştırmak için veritabanını sil. Outbox satırı kaç denemeden sonra Failed oluyor? LastError'da ne yazıyor?
★★☆ EmailTemplatesTests'e üç ayrı onay bekleyen bir İngilizce test ekle ve konu satırını kontrol et. Sonra EmailText.resx'e Almanca (de-DE) bir kopya ekle; çoğul kuralı hangi sayılarda farklı davranıyor?
★★★ Dispatcher'ı iki instance'ta çalışacak hâle getir: satırı göndermeden önce LockedUntilUtc alanıyla sahiplenen ve süresi geçmiş kilitleri geri alan bir sorgu yaz. İki dispatcher'ı aynı SQLite dosyasıyla aynı anda çalıştırıp hiçbir e-postanın iki kez gitmediğini Mailpit API'siyle doğrula.
E-posta tarafı tamam: gönderim, şablon ve güvenilirlik. Bir sonraki yazıda Blok B'ye geçiyoruz ve back office'in aylık satış raporunu ClosedXML ile biçimlendirilmiş bir Excel dosyası olarak üretiyor, 100 bin satırlık export'ta belleğe ne olduğunu ölçüyoruz.