using Application.Abstractions.Cache; using Application.Abstractions.Messaging.Email; using Application.Features.Config.Get; using Infrastructure.Persistence; using SharedKernel; using MailKit.Net.Smtp; using MailKit.Security; using MimeKit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Infrastructure.Messaging.Email; /// /// SMTP 직접 발송. Admin 단에서만 사용. (API는 QueuedMailService로 큐잉) /// public class DirectMailService : IMailService { private readonly ILogger _logger; private readonly ICacheService _cache; private readonly AppSettings.SmtpSection _smtp; private readonly AppDbContext _db; // SMTP 정보 public string Host { get; set; } = default!; public int Port { get; set; } = 587; public string? User { get; set; } public string? Password { get; set; } public bool UseStartTls { get; set; } = true; public string FromEmail { get; set; } = default!; public string FromName { get; set; } = "no-reply"; public DirectMailService(ILogger logger, ICacheService cache, IOptions options, AppDbContext db) { _logger = logger; _cache = cache; _smtp = options.Value.SMTP; _db = db; } public async Task SendAsync(SendData data, CancellationToken ct = default) { _logger.LogInformation("Sending email to {ToAddress} with subject {Subject}", data.ToAddress, data.Subject); // 1. Redis 캐시에서 Config 조회 var config = await _cache.GetAsync(CacheKeys.Config, ct); if (config is null) { var entity = await _db.Config.AsNoTracking().OrderByDescending(x => x.ID).FirstOrDefaultAsync(); if (entity is null) { Host = _smtp.Host; Port = _smtp.Port; User = _smtp.User; Password = _smtp.Password; UseStartTls = _smtp.UseStartTls; FromEmail = _smtp.FromEmail; FromName = _smtp.FromName; } else { Host = entity.Basic.SmtpServer ?? ""; Port = entity.Basic.SmtpPort ?? 587; UseStartTls = entity.Basic.SmtpEnableSSL; User = entity.Basic.SmtpUsername; Password = entity.Basic.SmtpPassword; FromEmail = entity.Basic.FromEmail ?? ""; FromName = entity.Basic.FromName ?? ""; } } else { Host = config.Basic.SmtpServer ?? _smtp.Host; Port = config.Basic.SmtpPort ?? _smtp.Port; UseStartTls = config.Basic.SmtpEnableSSL; User = config.Basic.SmtpUsername ?? _smtp.User; Password = config.Basic.SmtpPassword ?? _smtp.Password; FromEmail = config.Basic.FromEmail ?? _smtp.FromEmail; FromName = config.Basic.FromName ?? _smtp.FromName; } // FromEmail 의 도메인을 SMTP HELO/EHLO + Message-Id FQDN 으로 사용한다. // - 기본 hostname (예: "prod", "localhost") 으로 HELO 보내면 mail.dpot.dev rspamd 가 milter-reject (5.7.1). // - Message-Id 의 host part 가 FQDN 이 아니면 (예: <...@prod>) Gmail 등이 스팸함行 신호로 사용. var atIndex = FromEmail.IndexOf('@'); var fromDomain = atIndex >= 0 && atIndex < FromEmail.Length - 1 ? FromEmail[(atIndex + 1)..] : "dpot.dev"; var message = new MimeMessage(); message.From.Add(new MailboxAddress(FromName, FromEmail)); message.To.Add(MailboxAddress.Parse(data.ToAddress)); message.Subject = data.Subject; message.MessageId = MimeKit.Utils.MimeUtils.GenerateMessageId(fromDomain); var body = new BodyBuilder { HtmlBody = data.MessageHtml, TextBody = data.MessageText }; message.Body = body.ToMessageBody(); using var client = new SmtpClient { LocalDomain = fromDomain }; var secure = Port == 465 ? SecureSocketOptions.SslOnConnect : UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; await client.ConnectAsync(Host, Port, secure, ct); if (!string.IsNullOrWhiteSpace(User)) { await client.AuthenticateAsync(User, Password, ct); } await client.SendAsync(message, ct); await client.DisconnectAsync(true, ct); _logger.LogInformation("Email sent to {ToAddress} with subject {Subject}", data.ToAddress, data.Subject); } }