EmailService.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. using System.Collections.Concurrent;
  2. using System.Threading.Channels;
  3. using MailKit.Net.Smtp;
  4. using MailKit.Security;
  5. using MimeKit;
  6. using Microsoft.Extensions.Configuration;
  7. using Microsoft.Extensions.Logging;
  8. namespace Esim.SendMail.Services;
  9. /// <summary>
  10. /// High-performance email service optimized for millions of messages
  11. /// Features: Connection pooling, rate limiting, exponential backoff
  12. /// </summary>
  13. public interface IEmailService
  14. {
  15. Task<bool> SendEmailAsync(string to, string subject, string body, bool isHtml = true);
  16. Task<int> SendBatchAsync(IEnumerable<EmailMessage> messages, CancellationToken cancellationToken = default);
  17. int CalculateBackoffDelay(int retryCount);
  18. EmailMetrics GetMetrics();
  19. void Dispose();
  20. }
  21. public class EmailMessage
  22. {
  23. public decimal Id { get; set; }
  24. public string To { get; set; } = null!;
  25. public string Subject { get; set; } = null!;
  26. public string Body { get; set; } = null!;
  27. public bool IsHtml { get; set; } = true;
  28. }
  29. public class EmailResult
  30. {
  31. public decimal MessageId { get; set; }
  32. public bool Success { get; set; }
  33. public string? ErrorMessage { get; set; }
  34. public bool IsRetryable { get; set; }
  35. }
  36. public class EmailMetrics
  37. {
  38. public long TotalSent { get; set; }
  39. public long TotalSuccess { get; set; }
  40. public long TotalFailed { get; set; }
  41. public long TotalRetried { get; set; }
  42. public double AverageLatencyMs { get; set; }
  43. public int EmailsLastMinute { get; set; }
  44. public DateTime LastSentAt { get; set; }
  45. }
  46. public class HighPerformanceEmailService : IEmailService, IDisposable
  47. {
  48. private readonly ILogger<HighPerformanceEmailService> _logger;
  49. private readonly IConfiguration _configuration;
  50. private readonly string _smtpServer;
  51. private readonly int _smtpPort;
  52. private readonly string _senderEmail;
  53. private readonly string _senderName;
  54. private readonly string _senderPassword;
  55. private readonly bool _enableSsl;
  56. private readonly int _connectionPoolSize;
  57. private readonly int _maxConcurrentSends;
  58. private readonly int _maxEmailsPerMinute;
  59. private readonly int _baseRetryDelayMs;
  60. private readonly int _maxRetryDelayMs;
  61. // Connection pool for SMTP clients
  62. private readonly ConcurrentBag<SmtpClient> _connectionPool;
  63. private readonly SemaphoreSlim _poolSemaphore;
  64. // Rate limiting
  65. private readonly SemaphoreSlim _rateLimitSemaphore;
  66. private readonly ConcurrentQueue<DateTime> _sentTimestamps;
  67. private readonly object _rateLimitLock = new();
  68. // Metrics tracking
  69. private long _totalSent;
  70. private long _totalSuccess;
  71. private long _totalFailed;
  72. private long _totalRetried;
  73. private long _totalLatencyMs;
  74. private DateTime _lastSentAt;
  75. private bool _disposed = false;
  76. public HighPerformanceEmailService(ILogger<HighPerformanceEmailService> logger, IConfiguration configuration)
  77. {
  78. _logger = logger;
  79. _configuration = configuration;
  80. // SMTP configuration
  81. _smtpServer = _configuration["Email:SmtpServer"] ?? "smtp.gmail.com";
  82. _smtpPort = int.Parse(_configuration["Email:SmtpPort"] ?? "587");
  83. _senderEmail = _configuration["Email:SenderEmail"] ?? "";
  84. _senderName = _configuration["Email:SenderName"] ?? "EsimLao";
  85. _senderPassword = _configuration["Email:SenderPassword"] ?? "";
  86. _enableSsl = bool.Parse(_configuration["Email:EnableSsl"] ?? "true");
  87. // Performance configuration
  88. _connectionPoolSize = int.Parse(_configuration["Email:ConnectionPoolSize"] ?? "5");
  89. _maxConcurrentSends = int.Parse(_configuration["Email:MaxConcurrentSends"] ?? "10");
  90. _maxEmailsPerMinute = int.Parse(_configuration["Email:MaxEmailsPerMinute"] ?? "30");
  91. _baseRetryDelayMs = int.Parse(_configuration["Email:BaseRetryDelayMs"] ?? "2000");
  92. _maxRetryDelayMs = int.Parse(_configuration["Email:MaxRetryDelayMs"] ?? "60000");
  93. // Initialize pools
  94. _connectionPool = new ConcurrentBag<SmtpClient>();
  95. _poolSemaphore = new SemaphoreSlim(_connectionPoolSize, _connectionPoolSize);
  96. // Initialize rate limiting
  97. _rateLimitSemaphore = new SemaphoreSlim(_maxConcurrentSends, _maxConcurrentSends);
  98. _sentTimestamps = new ConcurrentQueue<DateTime>();
  99. // Pre-initialize connection pool
  100. InitializeConnectionPool();
  101. _logger.LogInformation(
  102. "EmailService initialized: Pool={PoolSize}, Concurrent={Concurrent}, RateLimit={RateLimit}/min",
  103. _connectionPoolSize, _maxConcurrentSends, _maxEmailsPerMinute);
  104. }
  105. private void InitializeConnectionPool()
  106. {
  107. _logger.LogInformation("Initializing SMTP connection pool with {PoolSize} connections", _connectionPoolSize);
  108. for (int i = 0; i < _connectionPoolSize; i++)
  109. {
  110. try
  111. {
  112. var client = CreateConnectedClient();
  113. if (client != null)
  114. {
  115. _connectionPool.Add(client);
  116. }
  117. }
  118. catch (Exception ex)
  119. {
  120. _logger.LogWarning("Failed to create initial SMTP connection {Index}: {Message}", i + 1, ex.Message);
  121. }
  122. }
  123. _logger.LogInformation("Connection pool initialized with {Count} connections", _connectionPool.Count);
  124. }
  125. private SmtpClient? CreateConnectedClient()
  126. {
  127. try
  128. {
  129. var client = new SmtpClient();
  130. client.Timeout = 30000; // 30 seconds
  131. client.Connect(_smtpServer, _smtpPort, _enableSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
  132. client.Authenticate(_senderEmail, _senderPassword);
  133. return client;
  134. }
  135. catch (Exception ex)
  136. {
  137. _logger.LogError("Failed to create SMTP connection: {Message}", ex.Message);
  138. return null;
  139. }
  140. }
  141. private async Task<SmtpClient?> GetClientFromPoolAsync(CancellationToken cancellationToken = default)
  142. {
  143. await _poolSemaphore.WaitAsync(cancellationToken);
  144. if (_connectionPool.TryTake(out var client))
  145. {
  146. if (client.IsConnected)
  147. {
  148. return client;
  149. }
  150. try { client.Dispose(); } catch { }
  151. }
  152. return CreateConnectedClient();
  153. }
  154. private void ReturnClientToPool(SmtpClient? client)
  155. {
  156. if (client != null && client.IsConnected)
  157. {
  158. _connectionPool.Add(client);
  159. }
  160. else
  161. {
  162. try
  163. {
  164. var newClient = CreateConnectedClient();
  165. if (newClient != null)
  166. {
  167. _connectionPool.Add(newClient);
  168. }
  169. }
  170. catch { }
  171. }
  172. _poolSemaphore.Release();
  173. }
  174. /// <summary>
  175. /// Apply rate limiting - waits if we've exceeded emails per minute
  176. /// </summary>
  177. private async Task ApplyRateLimitAsync(CancellationToken cancellationToken = default)
  178. {
  179. var now = DateTime.UtcNow;
  180. var oneMinuteAgo = now.AddMinutes(-1);
  181. // Clean up old timestamps
  182. while (_sentTimestamps.TryPeek(out var oldestTime) && oldestTime < oneMinuteAgo)
  183. {
  184. _sentTimestamps.TryDequeue(out _);
  185. }
  186. // Check if we need to wait
  187. while (_sentTimestamps.Count >= _maxEmailsPerMinute)
  188. {
  189. if (_sentTimestamps.TryPeek(out var oldestTime))
  190. {
  191. var waitTime = oldestTime.AddMinutes(1) - DateTime.UtcNow;
  192. if (waitTime > TimeSpan.Zero)
  193. {
  194. _logger.LogDebug("Rate limit reached, waiting {WaitMs}ms", waitTime.TotalMilliseconds);
  195. await Task.Delay(waitTime, cancellationToken);
  196. }
  197. }
  198. // Clean up again
  199. now = DateTime.UtcNow;
  200. oneMinuteAgo = now.AddMinutes(-1);
  201. while (_sentTimestamps.TryPeek(out var time) && time < oneMinuteAgo)
  202. {
  203. _sentTimestamps.TryDequeue(out _);
  204. }
  205. }
  206. // Record this send
  207. _sentTimestamps.Enqueue(DateTime.UtcNow);
  208. }
  209. /// <summary>
  210. /// Calculate exponential backoff delay
  211. /// </summary>
  212. public int CalculateBackoffDelay(int retryCount)
  213. {
  214. var delay = _baseRetryDelayMs * (int)Math.Pow(2, retryCount);
  215. return Math.Min(delay, _maxRetryDelayMs);
  216. }
  217. /// <summary>
  218. /// Determine if an error is retryable
  219. /// </summary>
  220. private static bool IsRetryableError(Exception ex)
  221. {
  222. // Network errors are retryable
  223. if (ex is System.Net.Sockets.SocketException) return true;
  224. if (ex is TimeoutException) return true;
  225. if (ex is OperationCanceledException) return false;
  226. // SMTP errors
  227. if (ex is SmtpCommandException smtpEx)
  228. {
  229. // 4xx errors are temporary, 5xx are permanent
  230. return smtpEx.StatusCode < MailKit.Net.Smtp.SmtpStatusCode.MailboxNameNotAllowed;
  231. }
  232. return true; // Default to retryable
  233. }
  234. public async Task<bool> SendEmailAsync(string to, string subject, string body, bool isHtml = true)
  235. {
  236. var startTime = DateTime.UtcNow;
  237. SmtpClient? client = null;
  238. try
  239. {
  240. // Apply rate limiting
  241. await ApplyRateLimitAsync();
  242. // Wait for concurrent send slot
  243. await _rateLimitSemaphore.WaitAsync();
  244. try
  245. {
  246. client = await GetClientFromPoolAsync();
  247. if (client == null)
  248. {
  249. _logger.LogError("Failed to get SMTP client from pool for {To}", to);
  250. Interlocked.Increment(ref _totalFailed);
  251. return false;
  252. }
  253. var message = CreateMimeMessage(to, subject, body, isHtml);
  254. await client.SendAsync(message);
  255. // Update metrics
  256. Interlocked.Increment(ref _totalSent);
  257. Interlocked.Increment(ref _totalSuccess);
  258. Interlocked.Add(ref _totalLatencyMs, (long)(DateTime.UtcNow - startTime).TotalMilliseconds);
  259. _lastSentAt = DateTime.UtcNow;
  260. _logger.LogDebug("Email sent to {To} in {ElapsedMs}ms", to, (DateTime.UtcNow - startTime).TotalMilliseconds);
  261. return true;
  262. }
  263. finally
  264. {
  265. _rateLimitSemaphore.Release();
  266. }
  267. }
  268. catch (Exception ex)
  269. {
  270. Interlocked.Increment(ref _totalSent);
  271. Interlocked.Increment(ref _totalFailed);
  272. _logger.LogError("Failed to send email to {To}: {Message}", to, ex.Message);
  273. // Force reconnect on error
  274. if (client != null)
  275. {
  276. try { client.Disconnect(true); } catch { }
  277. try { client.Dispose(); } catch { }
  278. client = null;
  279. }
  280. return false;
  281. }
  282. finally
  283. {
  284. ReturnClientToPool(client);
  285. }
  286. }
  287. /// <summary>
  288. /// Send batch of emails with parallel processing and rate limiting
  289. /// Returns number of successfully sent emails
  290. /// </summary>
  291. public async Task<int> SendBatchAsync(IEnumerable<EmailMessage> messages, CancellationToken cancellationToken = default)
  292. {
  293. var messageList = messages.ToList();
  294. if (!messageList.Any()) return 0;
  295. _logger.LogInformation("Sending batch of {Count} emails", messageList.Count);
  296. var successCount = 0;
  297. var channel = Channel.CreateBounded<EmailMessage>(new BoundedChannelOptions(_maxConcurrentSends * 2)
  298. {
  299. FullMode = BoundedChannelFullMode.Wait
  300. });
  301. // Producer task - writes messages to channel
  302. var producerTask = Task.Run(async () =>
  303. {
  304. foreach (var msg in messageList)
  305. {
  306. if (cancellationToken.IsCancellationRequested) break;
  307. await channel.Writer.WriteAsync(msg, cancellationToken);
  308. }
  309. channel.Writer.Complete();
  310. }, cancellationToken);
  311. // Consumer tasks - reads from channel and sends emails
  312. var consumerTasks = Enumerable.Range(0, _maxConcurrentSends).Select(async _ =>
  313. {
  314. var localSuccess = 0;
  315. await foreach (var msg in channel.Reader.ReadAllAsync(cancellationToken))
  316. {
  317. try
  318. {
  319. var result = await SendEmailAsync(msg.To, msg.Subject, msg.Body, msg.IsHtml);
  320. if (result) localSuccess++;
  321. }
  322. catch (Exception ex)
  323. {
  324. _logger.LogError(ex, "Failed to send email {Id}", msg.Id);
  325. }
  326. }
  327. return localSuccess;
  328. }).ToList();
  329. await producerTask;
  330. var results = await Task.WhenAll(consumerTasks);
  331. successCount = results.Sum();
  332. _logger.LogInformation("Batch complete: {SuccessCount}/{TotalCount} successful", successCount, messageList.Count);
  333. return successCount;
  334. }
  335. private MimeMessage CreateMimeMessage(string to, string subject, string body, bool isHtml)
  336. {
  337. var message = new MimeMessage();
  338. message.From.Add(new MailboxAddress(_senderName, _senderEmail));
  339. message.To.Add(MailboxAddress.Parse(to));
  340. message.Subject = subject;
  341. var bodyBuilder = new BodyBuilder();
  342. if (isHtml)
  343. {
  344. bodyBuilder.HtmlBody = body;
  345. }
  346. else
  347. {
  348. bodyBuilder.TextBody = body;
  349. }
  350. message.Body = bodyBuilder.ToMessageBody();
  351. return message;
  352. }
  353. public EmailMetrics GetMetrics()
  354. {
  355. var emailsLastMinute = 0;
  356. var oneMinuteAgo = DateTime.UtcNow.AddMinutes(-1);
  357. foreach (var timestamp in _sentTimestamps)
  358. {
  359. if (timestamp >= oneMinuteAgo) emailsLastMinute++;
  360. }
  361. return new EmailMetrics
  362. {
  363. TotalSent = Interlocked.Read(ref _totalSent),
  364. TotalSuccess = Interlocked.Read(ref _totalSuccess),
  365. TotalFailed = Interlocked.Read(ref _totalFailed),
  366. TotalRetried = Interlocked.Read(ref _totalRetried),
  367. AverageLatencyMs = _totalSent > 0 ? (double)Interlocked.Read(ref _totalLatencyMs) / _totalSent : 0,
  368. EmailsLastMinute = emailsLastMinute,
  369. LastSentAt = _lastSentAt
  370. };
  371. }
  372. public void Dispose()
  373. {
  374. if (_disposed) return;
  375. _disposed = true;
  376. _logger.LogInformation("Disposing email service...");
  377. while (_connectionPool.TryTake(out var client))
  378. {
  379. try
  380. {
  381. if (client.IsConnected)
  382. {
  383. client.Disconnect(true);
  384. }
  385. client.Dispose();
  386. }
  387. catch { }
  388. }
  389. _poolSemaphore.Dispose();
  390. _rateLimitSemaphore.Dispose();
  391. _logger.LogInformation("Email service disposed. Final metrics: Sent={Sent}, Success={Success}, Failed={Failed}",
  392. _totalSent, _totalSuccess, _totalFailed);
  393. }
  394. }