Files
live_recorder/src/LiveRecorder.Infrastructure/Services/EmailNotificationService.cs
T

267 lines
11 KiB
C#

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*(?<name>[a-zA-Z0-9_]+)\s*\}\}""", RegexOptions.Compiled);
private readonly ISystemSettingsService _systemSettingsService;
private readonly ILogger<EmailNotificationService> _logger;
public EmailNotificationService(
ISystemSettingsService systemSettingsService,
ILogger<EmailNotificationService> 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<string, string?>
{
["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<string, string?>
{
["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<string, string?>
{
["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<string, string?>
{
["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 = $$"""
<!DOCTYPE html>
<html lang="en">
<body style="margin: 0; padding: 24px; background: #f5f7fb; font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937;">
<div style="max-width: 880px; margin: 0 auto;">
<h1 style="margin: 0 0 20px; color: #2f4c66;">LiveRecorder SMTP template test</h1>
<div style="margin-bottom: 18px; padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;">
<h2 style="margin: 0 0 14px; color: #3e5f7c;">开播提醒示例</h2>
<div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailLiveStartedSubjectTemplate, sampleLiveTokens))}}</div>
{{RenderHtml(settings.EmailLiveStartedBodyTemplateHtml, sampleLiveTokens)}}
</div>
<div style="padding: 18px 20px; border-radius: 12px; background: #ffffff; border: 1px solid #d9e1ea;">
<h2 style="margin: 0 0 14px; color: #8b5e3c;">异常提醒示例</h2>
<div style="margin-bottom: 10px; font-size: 13px; color: #6b7280;"><strong>Subject:</strong> {{WebUtility.HtmlEncode(RenderSubject(settings.EmailExceptionSubjectTemplate, sampleExceptionTokens))}}</div>
{{RenderHtml(settings.EmailExceptionBodyTemplateHtml, sampleExceptionTokens)}}
</div>
</div>
</body>
</html>
""";
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<string> ParseRecipients(string rawAddresses) =>
rawAddresses
.Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static Dictionary<string, string> CreateTokenMap(IReadOnlyDictionary<string, string?> 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<string, string> 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<string, string> tokens)
{
var rendered = RenderTemplate(template, tokens, htmlEncodeValues: true).Trim();
return string.IsNullOrWhiteSpace(rendered) ? "<div></div>" : rendered;
}
private static string RenderTemplate(string template, IReadOnlyDictionary<string, string> 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;
});
}
}