using dy.net.model.dto; using dy.net.model.entity; using MailKit.Security; using Microsoft.AspNetCore.DataProtection; using MimeKit; using SqlSugar; namespace dy.net.service { public sealed class EmailNotificationSettingsService { private const string SettingsId = "default"; private readonly ISqlSugarClient _db; private readonly IDataProtector _protector; private readonly IEmailNotificationSender _sender; public EmailNotificationSettingsService( ISqlSugarClient db, IDataProtectionProvider provider, IEmailNotificationSender sender) { _db = db; _protector = provider.CreateProtector("dysync.email.password.v1"); _sender = sender; } public async Task GetAsync() => await _db.Queryable().InSingleAsync(SettingsId) ?? new EmailNotificationSettings(); public async Task GetPasswordAsync(EmailNotificationSettings settings = null) { settings ??= await GetAsync(); if (string.IsNullOrWhiteSpace(settings.ProtectedPassword)) return string.Empty; try { return _protector.Unprotect(settings.ProtectedPassword); } catch (Exception ex) { Serilog.Log.Error(ex, "SMTP 密码解密失败,请重新输入密码"); return string.Empty; } } public async Task BuildCandidateAsync(EmailNotificationSettingsDto dto) { dto ??= new EmailNotificationSettingsDto(); var existing = await GetAsync(); var password = string.IsNullOrWhiteSpace(dto.Password) ? await GetPasswordAsync(existing) : dto.Password; var candidate = new EmailNotificationSettings { Id = SettingsId, Enabled = dto.Enabled, Host = dto.Host?.Trim(), Port = dto.Port <= 0 ? 465 : dto.Port, SecurityMode = dto.SecurityMode, UserName = dto.UserName?.Trim(), ProtectedPassword = string.IsNullOrWhiteSpace(password) ? null : _protector.Protect(password), FromAddress = dto.FromAddress?.Trim(), FromName = Limit(dto.FromName, 200), Recipients = NormalizeRecipients(dto.Recipients), LastTestedAt = existing.LastTestedAt, LastTestMessage = existing.LastTestMessage }; Validate(candidate, password, requireComplete: candidate.Enabled); return candidate; } public async Task SaveAsync(EmailNotificationSettingsDto dto) { var candidate = await BuildCandidateAsync(dto); await _db.Storageable(candidate).ExecuteCommandAsync(); return ToDto(candidate); } public async Task TestAsync(EmailNotificationSettingsDto dto, CancellationToken cancellationToken) { var candidate = await BuildCandidateAsync(dto); var password = await GetPasswordAsync(candidate); Validate(candidate, password, requireComplete: true); var now = DateTime.Now; await _sender.SendAsync( candidate, password, "dysync.net 邮箱通知测试成功", $"

邮箱通知配置可用

测试时间:{now:yyyy-MM-dd HH:mm:ss}

开播通知将只在博主首次进入直播状态时发送。

", cancellationToken); candidate.LastTestedAt = DateTime.Now; candidate.LastTestMessage = "测试邮件发送成功"; await _db.Storageable(candidate).ExecuteCommandAsync(); return candidate.LastTestMessage; } public async Task GetReadinessErrorAsync() { var settings = await GetAsync(); if (!settings.Enabled) return "请先在系统设置中启用邮箱通知"; var password = await GetPasswordAsync(settings); try { Validate(settings, password, requireComplete: true); return null; } catch (InvalidOperationException ex) { return "邮箱通知配置不完整:" + ex.Message; } } public EmailNotificationSettingsDto ToDto(EmailNotificationSettings settings) => new() { Enabled = settings.Enabled, Host = settings.Host, Port = settings.Port, SecurityMode = settings.SecurityMode, UserName = settings.UserName, HasPassword = !string.IsNullOrWhiteSpace(settings.ProtectedPassword), FromAddress = settings.FromAddress, FromName = settings.FromName, Recipients = settings.Recipients, LastTestedAt = settings.LastTestedAt, LastTestMessage = settings.LastTestMessage }; public static SecureSocketOptions ToSocketOptions(EmailSecurityMode mode) => mode switch { EmailSecurityMode.None => SecureSocketOptions.None, EmailSecurityMode.StartTls => SecureSocketOptions.StartTls, _ => SecureSocketOptions.SslOnConnect }; private static void Validate(EmailNotificationSettings settings, string password, bool requireComplete) { if (settings.Port is < 1 or > 65535) throw new InvalidOperationException("SMTP 端口必须在 1–65535 之间"); if (!requireComplete) return; if (string.IsNullOrWhiteSpace(settings.Host)) throw new InvalidOperationException("请填写 SMTP 服务器"); if (string.IsNullOrWhiteSpace(settings.FromAddress) || !MailboxAddress.TryParse(settings.FromAddress, out _)) throw new InvalidOperationException("发件邮箱格式不正确"); if (ParseRecipients(settings.Recipients).Count == 0) throw new InvalidOperationException("请至少填写一个收件邮箱"); if (!string.IsNullOrWhiteSpace(settings.UserName) && string.IsNullOrWhiteSpace(password)) throw new InvalidOperationException("请填写 SMTP 密码或授权码"); } public static List ParseRecipients(string recipients) { var result = new List(); foreach (var value in (recipients ?? string.Empty) .Split(new[] { ',', ';', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Take(20)) { if (!MailboxAddress.TryParse(value, out var address)) throw new InvalidOperationException($"收件邮箱格式不正确:{value}"); result.Add(address); } return result; } private static string NormalizeRecipients(string recipients) => string.Join(";", ParseRecipients(recipients).Select(x => x.Address)); private static string Limit(string value, int maxLength) { value = value?.Trim(); return value != null && value.Length > maxLength ? value[..maxLength] : value; } } }