Changed Background mail worker to use new email api

This commit is contained in:
Mia Rose Winter 2024-02-18 15:06:14 +01:00
parent 37ffb49ca1
commit b86d6968d6
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
2 changed files with 92 additions and 118 deletions

View file

@ -1,133 +1,40 @@
using System.Net;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using MimeKit;
using Microsoft.Extensions.Options;
using Wave.Data;
using Wave.Utilities;
namespace Wave.Services;
public class EmailBackgroundWorker(ILogger<EmailBackgroundWorker> logger, IDbContextFactory<ApplicationDbContext> contextFactory, IOptions<SmtpConfiguration> config, IOptions<Customization> customizations, IOptions<Features> features, EmailTemplateService templateService) : IHostedService, IDisposable {
public class EmailBackgroundWorker(ILogger<EmailBackgroundWorker> logger, IOptions<Features> features, EmailTemplateService templateService, IServiceProvider serviceProvider) : BackgroundService {
private ILogger<EmailBackgroundWorker> Logger { get; } = logger;
private IDbContextFactory<ApplicationDbContext> ContextFactory { get; } = contextFactory;
private SmtpConfiguration Configuration { get; } = config.Value;
private Customization Customizations { get; } = customizations.Value;
private Features Features { get; } = features.Value;
private EmailTemplateService TemplateService { get; } = templateService;
private Timer? Timer { get; set; }
public Task StartAsync(CancellationToken cancellationToken) {
if (!Features.EmailSubscriptions) return Task.CompletedTask;
Logger.LogInformation("Background email worker starting.");
// we want this timer to execute every 15 minutes, at fixed times (:00, :15, :30, :45)
var now = DateTimeOffset.UtcNow;
int nowMinute = now.Minute;
int waitTime = 15 - nowMinute % 15;
Logger.LogInformation("First distribution check will be in {waitTime} minutes, at {time}.",
waitTime, now.AddMinutes(waitTime).LocalDateTime.ToString("u"));
Timer = new Timer(DoWork, null, TimeSpan.FromMinutes(waitTime), TimeSpan.FromMinutes(15));
private IServiceProvider ServiceProvider { get; } = serviceProvider;
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
if (!Features.EmailSubscriptions) return;
TemplateService.TryCreateDefaultTemplates();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) {
if (!Features.EmailSubscriptions) return Task.CompletedTask;
Logger.LogInformation("Background email worker stopping.");
Timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose() {
Timer?.Dispose();
GC.SuppressFinalize(this);
}
private void DoWork(object? _) {
Logger.LogInformation("Background email worker starting.");
try {
Logger.LogInformation("Checking Articles...");
using var context = ContextFactory.CreateDbContext();
// we want this timer to execute every 15 minutes, at fixed times (:00, :15, :30, :45)
var now = DateTimeOffset.UtcNow;
var newsletters = context.Set<EmailNewsletter>()
.Include(n => n.Article.Author)
.Include(n => n.Article.Categories)
.Where(n => !n.IsSend && n.DistributionDateTime <= now)
.ToList();
if (newsletters.Count < 1) return;
int nowMinute = now.Minute;
int waitTime = 15 - nowMinute % 15;
// we always want to start a little bit later than :00:00, to make sure we actually distribute the :00:00 newsletters
if (now.Second < 3) await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
Logger.LogInformation("First distribution check will be in {waitTime} minutes, at {time}.",
waitTime, now.AddMinutes(waitTime).LocalDateTime.ToString("u"));
await Task.Delay(TimeSpan.FromMinutes(waitTime), stoppingToken);
Logger.LogInformation("Processing {count} Articles...", newsletters.Count);
var sender = new MailboxAddress(Configuration.SenderName, Configuration.SenderEmail);
using var client = new SmtpClient();
client.Connect(Configuration.Host, Configuration.Port,
Configuration.Ssl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.None);
if (!string.IsNullOrWhiteSpace(Configuration.Username)) {
client.Authenticate(Configuration.Username, Configuration.Password);
}
var host = new Uri(Customizations.AppUrl, UriKind.Absolute);
foreach (var newsletter in newsletters) {
Logger.LogInformation("Processing '{title}'.", newsletter.Article.Title);
// set newsletter to send first, so we don't spam people
// in case something unforeseen goes wrong
newsletter.IsSend = true;
context.SaveChanges();
string articleLink = ArticleUtilities.GenerateArticleLink(newsletter.Article, new Uri(Customizations.AppUrl, UriKind.Absolute));
string template = TemplateService.Newsletter(host.AbsoluteUri, articleLink,
(!string.IsNullOrWhiteSpace(Customizations.LogoLink) ?
new Uri(Customizations.LogoLink) :
new Uri(host, "/img/logo.png")).AbsoluteUri, newsletter.Article.Title, newsletter.Article.BodyHtml,
"[[<__UNSUBSCRIBE__>]]");
var message = new MimeMessage {
From = { sender },
Subject = newsletter.Article.Title
};
EmailSubscriber? last = null;
while (context.Set<EmailSubscriber>()
.Where(s => (last == null || s.Id > last.Id))
.OrderBy(s => s.Id)
.Take(50)
.ToList() is { Count: > 0 } subscribers) {
last = subscribers.Last();
foreach (var subscriber in subscribers) {
(string user, string token) = TemplateService.CreateConfirmTokensAsync(subscriber.Id, "unsubscribe-" + newsletter.Id, TimeSpan.FromDays(30)).ConfigureAwait(false).GetAwaiter().GetResult();
string unsubscribeLink = new Uri(host,
$"/Email/Unsubscribe?newsletter={newsletter.Id:D}&user={WebUtility.UrlEncode(user)}&token={WebUtility.UrlEncode(token)}").AbsoluteUri;
var builder = new BodyBuilder {
HtmlBody = template.Replace("[[<__UNSUBSCRIBE__>]]", unsubscribeLink)
};
message.To.Clear();
// TODO mailto: unsubscribe:
// List-Unsubscribe: <mailto: unsubscribe@example.com?subject=unsubscribe>, <http://www.example.com/unsubscribe.html>
message.Headers.Add(HeaderId.ListUnsubscribe, $"<{unsubscribeLink}>");
message.Headers.Add(HeaderId.ListUnsubscribePost, "One-Click");
message.To.Add(new MailboxAddress(subscriber.Name, subscriber.Email));
message.Body = builder.ToMessageBody();
client.Send(message);
}
Task.Delay(TimeSpan.FromSeconds(10)).Wait();
}
}
client.Disconnect(true);
Logger.LogInformation("Processing complete.");
} catch (Exception ex) {
Logger.LogError(ex, "Failed to distribute emails.");
using PeriodicTimer timer = new(TimeSpan.FromMinutes(15));
do {
await using var scope = ServiceProvider.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<NewsletterBackgroundService>();
await service.DoWork(stoppingToken);
} while (await timer.WaitForNextTickAsync(stoppingToken));
} catch (OperationCanceledException) {
Logger.LogInformation("Background email worker stopping.");
}
}
}

View file

@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Wave.Data;
using Wave.Utilities;
namespace Wave.Services;
public class NewsletterBackgroundService(ILogger<NewsletterBackgroundService> logger, IDbContextFactory<ApplicationDbContext> contextFactory, IServiceProvider serviceProvider, IOptions<Customization> customizations) : IScopedProcessingService {
private ILogger<NewsletterBackgroundService> Logger { get; } = logger;
private IDbContextFactory<ApplicationDbContext> ContextFactory { get; } = contextFactory;
private IServiceProvider ServiceProvider { get; } = serviceProvider;
private Customization Customizations { get; } = customizations.Value;
public async ValueTask DoWork(CancellationToken cancellationToken) {
try {
Logger.LogInformation("Checking Articles...");
await using var context = await ContextFactory.CreateDbContextAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
var newsletters = context.Set<EmailNewsletter>()
.Include(n => n.Article.Author)
.Include(n => n.Article.Categories)
.Where(n => !n.IsSend && n.DistributionDateTime <= now)
.ToList();
if (newsletters.Count < 1) return;
Logger.LogInformation("Processing {count} Articles...", newsletters.Count);
await using var client = ServiceProvider.GetRequiredKeyedService<IEmailService>("bulk");
await client.ConnectAsync(cancellationToken);
var factory = ServiceProvider.GetRequiredService<EmailFactory>();
foreach (var newsletter in newsletters) {
if (cancellationToken.IsCancellationRequested) {
Logger.LogInformation("Cancellation requested, skipping processing '{title}'.", newsletter.Article.Title);
return;
}
Logger.LogInformation("Processing '{title}'.", newsletter.Article.Title);
// set newsletter to send first, so we don't spam people
// in case something unforeseen goes wrong
newsletter.IsSend = true;
await context.SaveChangesAsync(cancellationToken);
string articleLink = ArticleUtilities.GenerateArticleLink(newsletter.Article, new Uri(Customizations.AppUrl, UriKind.Absolute));
EmailSubscriber? last = null;
while (context.Set<EmailSubscriber>()
.Where(s => (last == null || s.Id > last.Id))
.OrderBy(s => s.Id)
.Take(50)
.ToList() is { Count: > 0 } subscribers) {
last = subscribers.Last();
foreach (var subscriber in subscribers) {
var email = await factory.CreateSubscribedEmail(subscriber, articleLink, newsletter.Article.Title,
newsletter.Article.Title, newsletter.Article.BodyHtml, newsletter.Id.ToString());
await client.SendEmailAsync(email);
}
}
}
Logger.LogInformation("Processing complete.");
} catch (Exception ex) {
Logger.LogError(ex, "Failed to distribute emails.");
}
}
}