using System.Net; using System.Net.Mail; using System.Text; using System.Text.RegularExpressions; using LiveRecorder.Application.Abstractions.Notifications; using LiveRecorder.Application.Abstractions.Settings; using LiveRecorder.Application.Common; using LiveRecorder.Application.Models.Settings; using LiveRecorder.Domain.Entities; using Microsoft.Extensions.Logging; namespace LiveRecorder.Infrastructure.Services; public sealed class EmailNotificationService : IEmailNotificationService { private const string AppName = "LiveRecorder"; private static readonly Regex TemplateTokenRegex = new("""\{\{\s*(?[a-zA-Z0-9_]+)\s*\}\}""", RegexOptions.Compiled); private readonly ISystemSettingsService _systemSettingsService; private readonly ILogger _logger; public EmailNotificationService( ISystemSettingsService systemSettingsService, ILogger logger) { _systemSettingsService = systemSettingsService; _logger = logger; } public async Task SendLiveStartedAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default) { var settings = await _systemSettingsService.GetAsync(cancellationToken); if (!settings.EnableEmailNotification || !settings.NotifyOnLiveStarted) { return; } var tokens = CreateTokenMap(new Dictionary { ["platform"] = liveRoom.Platform.ToString(), ["roomId"] = liveRoom.RoomId, ["title"] = liveRoom.Title, ["anchor"] = liveRoom.AnchorName, ["sourceUrl"] = liveRoom.SourceUrl, ["detectedAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O") }); var subject = RenderSubject(settings.EmailLiveStartedSubjectTemplate, tokens); var body = RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, tokens); await SendAsync(settings, subject, body, cancellationToken); } public async Task SendExceptionAsync( string source, string summary, string? detail = null, LiveRoom? liveRoom = null, RecordTask? recordTask = null, CancellationToken cancellationToken = default) { var settings = await _systemSettingsService.GetAsync(cancellationToken); if (!settings.EnableEmailNotification || !settings.NotifyOnException) { return; } var tokens = CreateTokenMap(new Dictionary { ["source"] = source, ["summary"] = summary, ["detail"] = detail, ["liveRoomId"] = liveRoom?.Id.ToString(), ["roomId"] = liveRoom?.RoomId, ["recordTaskId"] = recordTask?.Id.ToString(), ["taskStatus"] = recordTask?.Status.ToString(), ["occurredAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O") }); var subject = RenderSubject(settings.EmailExceptionSubjectTemplate, tokens); var body = RenderHtml(settings.EmailExceptionBodyTemplateHtml, tokens); await SendAsync(settings, subject, body, cancellationToken); } public async Task SendTestAsync(SendTestEmailRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var settings = new SystemSettingsDto { EnableEmailNotification = true, NotifyOnLiveStarted = true, NotifyOnException = true, EmailSmtpHost = request.EmailSmtpHost.Trim(), EmailSmtpPort = request.EmailSmtpPort, EmailUseSsl = request.EmailUseSsl, EmailUsername = request.EmailUsername.Trim(), EmailPassword = request.EmailPassword, EmailFromAddress = request.EmailFromAddress.Trim(), EmailFromDisplayName = request.EmailFromDisplayName.Trim(), EmailToAddresses = request.EmailToAddresses.Trim(), EmailLiveStartedSubjectTemplate = request.EmailLiveStartedSubjectTemplate, EmailLiveStartedBodyTemplateHtml = request.EmailLiveStartedBodyTemplateHtml, EmailExceptionSubjectTemplate = request.EmailExceptionSubjectTemplate, EmailExceptionBodyTemplateHtml = request.EmailExceptionBodyTemplateHtml }; var sampleLiveTokens = CreateTokenMap(new Dictionary { ["platform"] = "Douyin", ["roomId"] = "123456789", ["title"] = "Sample Live Title", ["anchor"] = "Sample Anchor", ["sourceUrl"] = "https://live.douyin.com/123456789", ["detectedAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O") }); var sampleExceptionTokens = CreateTokenMap(new Dictionary { ["source"] = "Scheduler", ["summary"] = "Background polling failed for a live room.", ["detail"] = "Sample stack trace or diagnostic detail goes here.", ["liveRoomId"] = Guid.NewGuid().ToString(), ["roomId"] = "123456789", ["recordTaskId"] = Guid.NewGuid().ToString(), ["taskStatus"] = "Running", ["occurredAtUtc"] = ChinaTime.ToBeijingTime(DateTimeOffset.UtcNow).ToString("O") }); var body = $$"""

LiveRecorder SMTP template test

开播提醒示例

Subject: {{WebUtility.HtmlEncode(RenderSubject(settings.EmailLiveStartedSubjectTemplate, sampleLiveTokens))}}
{{RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, sampleLiveTokens)}}

异常提醒示例

Subject: {{WebUtility.HtmlEncode(RenderSubject(settings.EmailExceptionSubjectTemplate, sampleExceptionTokens))}}
{{RenderHtml(settings.EmailExceptionBodyTemplateHtml, sampleExceptionTokens)}}
"""; await SendAsync(settings, "[LiveRecorder] SMTP template test", body, cancellationToken, swallowErrors: false); } private async Task SendAsync( SystemSettingsDto settings, string subject, string body, CancellationToken cancellationToken, bool swallowErrors = true) { var recipients = ParseRecipients(settings.EmailToAddresses); if (string.IsNullOrWhiteSpace(settings.EmailSmtpHost) || string.IsNullOrWhiteSpace(settings.EmailFromAddress) || recipients.Count == 0) { if (swallowErrors) { return; } throw new InvalidOperationException("SMTP host, sender address or recipient list is missing."); } try { using var message = new MailMessage { From = new MailAddress(settings.EmailFromAddress, settings.EmailFromDisplayName), Subject = subject, Body = body, BodyEncoding = Encoding.UTF8, SubjectEncoding = Encoding.UTF8, IsBodyHtml = true }; foreach (var recipient in recipients) { message.To.Add(recipient); } using var client = new SmtpClient(settings.EmailSmtpHost, settings.EmailSmtpPort) { EnableSsl = settings.EmailUseSsl, DeliveryMethod = SmtpDeliveryMethod.Network }; if (!string.IsNullOrWhiteSpace(settings.EmailUsername)) { client.Credentials = new NetworkCredential(settings.EmailUsername, settings.EmailPassword); } using var registration = cancellationToken.Register(client.SendAsyncCancel); await client.SendMailAsync(message); } catch (Exception ex) { if (!swallowErrors) { throw new InvalidOperationException($"SMTP test email send failed: {ex.Message}", ex); } _logger.LogWarning(ex, "Email notification send failed"); } } private static List ParseRecipients(string rawAddresses) => rawAddresses .Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(static item => !string.IsNullOrWhiteSpace(item)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); private static Dictionary CreateTokenMap(IReadOnlyDictionary rawTokens) { var tokens = rawTokens.ToDictionary( static pair => pair.Key, static pair => pair.Value?.Trim() ?? string.Empty, StringComparer.OrdinalIgnoreCase); tokens["appName"] = AppName; return tokens; } private static string RenderSubject(string template, IReadOnlyDictionary tokens) { var rendered = RenderTemplate(template, tokens, htmlEncodeValues: false); rendered = rendered.Replace("\r", " ").Replace("\n", " ").Trim(); return string.IsNullOrWhiteSpace(rendered) ? $"[{AppName}] Notification" : rendered; } private static string RenderHtml(string template, IReadOnlyDictionary tokens) { var rendered = RenderTemplate(template, tokens, htmlEncodeValues: true).Trim(); return string.IsNullOrWhiteSpace(rendered) ? "
" : rendered; } private static string RenderTemplate(string template, IReadOnlyDictionary tokens, bool htmlEncodeValues) { if (string.IsNullOrWhiteSpace(template)) { return string.Empty; } return TemplateTokenRegex.Replace( template, match => { var tokenName = match.Groups["name"].Value; if (!tokens.TryGetValue(tokenName, out var value)) { return string.Empty; } return htmlEncodeValues ? WebUtility.HtmlEncode(value) : value; }); } }