From b86d6968d6059876640e326aa08d76b7025baea7 Mon Sep 17 00:00:00 2001 From: Mia Winter Date: Sun, 18 Feb 2024 15:06:14 +0100 Subject: [PATCH] Changed Background mail worker to use new email api --- Wave/Services/EmailBackgroundWorker.cs | 143 ++++--------------- Wave/Services/NewsletterBackgroundService.cs | 67 +++++++++ 2 files changed, 92 insertions(+), 118 deletions(-) create mode 100644 Wave/Services/NewsletterBackgroundService.cs diff --git a/Wave/Services/EmailBackgroundWorker.cs b/Wave/Services/EmailBackgroundWorker.cs index 8cd907d..29b9795 100644 --- a/Wave/Services/EmailBackgroundWorker.cs +++ b/Wave/Services/EmailBackgroundWorker.cs @@ -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 logger, IDbContextFactory contextFactory, IOptions config, IOptions customizations, IOptions features, EmailTemplateService templateService) : IHostedService, IDisposable { +public class EmailBackgroundWorker(ILogger logger, IOptions features, EmailTemplateService templateService, IServiceProvider serviceProvider) : BackgroundService { private ILogger Logger { get; } = logger; - private IDbContextFactory 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() - .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() - .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: , - 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(); + await service.DoWork(stoppingToken); + } while (await timer.WaitForNextTickAsync(stoppingToken)); + } catch (OperationCanceledException) { + Logger.LogInformation("Background email worker stopping."); } } } \ No newline at end of file diff --git a/Wave/Services/NewsletterBackgroundService.cs b/Wave/Services/NewsletterBackgroundService.cs new file mode 100644 index 0000000..22e151f --- /dev/null +++ b/Wave/Services/NewsletterBackgroundService.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Wave.Data; +using Wave.Utilities; + +namespace Wave.Services; + +public class NewsletterBackgroundService(ILogger logger, IDbContextFactory contextFactory, IServiceProvider serviceProvider, IOptions customizations) : IScopedProcessingService { + private ILogger Logger { get; } = logger; + private IDbContextFactory 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() + .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("bulk"); + await client.ConnectAsync(cancellationToken); + var factory = ServiceProvider.GetRequiredService(); + + 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() + .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."); + } + } +} \ No newline at end of file