public interface IEmailService{ // Arka planda (fire-and-forget) gönderim — UI akışını bloklamaz void SendBackground(string to, string subject, EmailTemplateType templateType, object model); // Beklemeli (awaitable) şablonlu gönderim Task<bool> SendAsync(string to, string subject, EmailTemplateType templateType, object model, CancellationToken ct = default); // Şablonsuz, hazır HTML gönderimi Task<bool> SendRawAsync(string to, string subject, string htmlContent, CancellationToken ct = default);}
SendBackground, Task.Run içinde SendAsync’i çağırır; hata olursa loglar ama fırlatmaz — login akışı gibi kullanıcıyı bekletmemesi gereken yerlerde kullanılır.
Şablonlar Templates/*.html altında tutulur ve Scriban ile render edilir. Hangi dosyanın kullanılacağı EmailTemplateType enumeration’ı belirler:
public class EmailTemplateType : Enumeration{ public static EmailTemplateType Welcome = new(1, "welcome-template"); public static EmailTemplateType OtpVerification = new(2, "otp-verification"); public static EmailTemplateType MagicLink = new(3, "magic-link"); public static EmailTemplateType AccountAlert = new(4, "account-alert"); public static EmailTemplateType DailyReport = new(5, "daily-report"); public static EmailTemplateType IntegrationError = new(6, "integration-error"); public static EmailTemplateType SubscriptionExpiring= new(7, "subscription-expiring"); public static EmailTemplateType PaymentSuccess = new(8, "payment-success");}
Name aynı zamanda dosya adıdır. Render sırasında dosya AppContext.BaseDirectory/Templates/{Name}.html yolundan okunur (Docker/K8s’te path güvenliği için AppContext.BaseDirectory kullanılır):
private async Task<string> RenderScribanTemplate(string templateFileName, object model){ string templatePath = Path.Combine(AppContext.BaseDirectory, "Templates", $"{templateFileName}.html"); if (!File.Exists(templatePath)) throw new EmailServiceException($"Şablon dosyası bulunamadı: {templatePath}"); string rawContent = await File.ReadAllTextAsync(templatePath); var template = Template.Parse(rawContent); if (template.HasErrors) throw new EmailServiceException($"Scriban şablon hatası: {string.Join(" | ", template.Messages)}"); return await template.RenderAsync(model);}
Şablon HTML dosyalarının çıktıya kopyalanması için .csproj’da CopyToOutputDirectory ayarı gerekir; aksi halde AppContext.BaseDirectory/Templates altında bulunamaz.
Production dışı ortamlarda gerçek alıcıya gönderim yapılmaz — gerçek müşterilere yanlışlıkla mail gitmesini engellemek için tüm gönderim Services:Email:Mails[] listesindeki test adreslerine yönlendirilir:
private string GetToAddress(string to){ if (_environment.IsProduction()) return to; if (_options.Value.Mails == null || !_options.Value.Mails.Any()) throw new EmailServiceException("Dev/Staging ortamı için test e-postası tanımlanmamış."); return string.Join(',', _options.Value.Mails.Select(m => m.Trim()));}
Cc ve Bcc adresleri ise hem çağrıdan gelen hem de config’teki değerlerin birleşiminden (distinct) oluşur.
Gerçek gönderim internal ResilientEmailGateway içinde olur. MailKit SmtpClient ile bağlanır; EnableSsltrue ise SslOnConnect, değilse StartTls kullanılır (Brevo için StartTls önerilir):
_policyWrapper = Policy.WrapAsync( CreateRetryPolicy(_options.CurrentValue.RetryCount), // WaitAndRetry, 2^attempt sn CreateCircuitBreakerPolicy()); // 6 hata → 1 dk açık kalır
Circuit-breaker eşiği koddadır: 6 ardışık hata sonrası devre 1 dakika açık kalır (exceptionsAllowedBeforeBreaking: 6, durationOfBreak: 1 dk). Retry sayısı ise config’ten (RetryCount, default 2) gelir.
OTP e-postası gönderimi, UserOtpGeneratedDomainEvent dinleyen bir domain event handler içinde yapılır (örn. SendOtpEmailDomainEventHandler). Login gibi kritik akışlarda fire-and-forget tercih edilir:
_emailService.SendBackground( to: user.Email.Value, subject: "Giriş doğrulama kodunuz", templateType: EmailTemplateType.OtpVerification, model: new { Code = otpCode.Value, Name = user.FullName?.Value });