From 300848a0bb5100a2a68e29f6dbd57715d7c6aca9 Mon Sep 17 00:00:00 2001 From: Mia Winter Date: Tue, 13 Feb 2024 15:35:24 +0100 Subject: [PATCH] Implemented http unsubscribe for mail newsletter --- Wave/Components/Pages/EmailEdit.razor | 111 ++++++++++++++++ .../Components/Pages/EmailEdit.de-DE.resx | 125 ++++++++++++++++++ .../Components/Pages/EmailEdit.en-GB.resx | 101 ++++++++++++++ .../Resources/Components/Pages/EmailEdit.resx | 125 ++++++++++++++++++ Wave/Services/EmailBackgroundWorker.cs | 31 +++-- Wave/Services/EmailTemplateService.cs | 6 +- Wave/Services/SmtpEmailSender.cs | 11 +- 7 files changed, 492 insertions(+), 18 deletions(-) create mode 100644 Wave/Components/Pages/EmailEdit.razor create mode 100644 Wave/Resources/Components/Pages/EmailEdit.de-DE.resx create mode 100644 Wave/Resources/Components/Pages/EmailEdit.en-GB.resx create mode 100644 Wave/Resources/Components/Pages/EmailEdit.resx diff --git a/Wave/Components/Pages/EmailEdit.razor b/Wave/Components/Pages/EmailEdit.razor new file mode 100644 index 0000000..04d7607 --- /dev/null +++ b/Wave/Components/Pages/EmailEdit.razor @@ -0,0 +1,111 @@ +@page "/Email/Unsubscribe" +@using Microsoft.AspNetCore.Identity.UI.Services +@using Microsoft.EntityFrameworkCore +@using Microsoft.Extensions.Options +@using Wave.Data +@using Wave.Services + +@inject ILogger Logger +@inject IStringLocalizer Localizer +@inject IDbContextFactory ContextFactory +@inject IOptions Customizations +@inject NavigationManager Navigation +@inject IEmailSender EmailSender +@inject EmailTemplateService TemplateService + +@(TitlePrefix + Localizer["Title"]) + +@if (!string.IsNullOrWhiteSpace(Message)) { +
+ @Message +
+} + + + +
+ + + + + + + + +
+
+ +@code { + [CascadingParameter(Name = "TitlePrefix")] + private string TitlePrefix { get; set; } = default!; + + [Parameter, SupplyParameterFromQuery(Name = "user")] + public string? Id { get; set; } + [Parameter, SupplyParameterFromQuery(Name = "token")] + public string? Token { get; set; } + [Parameter, SupplyParameterFromQuery(Name = "newsletter")] + public int? Newsletter { get; set; } + + private string Message { get; set; } = string.Empty; + + protected override async Task OnInitializedAsync() { + if (Id is null || Token is null || Newsletter is null) { + if (string.IsNullOrWhiteSpace(Message)) Message = Localizer["Load_Failure_Message"]; + return; + } + + try { + if (await GetSubscriber() is null) { + Message = Localizer["Load_Failure_Message"]; + } + } catch (Exception) { + Message = Localizer["Load_Failure_Message"]; + } + } + + private async Task Unsubscribe_Submit() { + try { + await using var context = await ContextFactory.CreateDbContextAsync(); + var subscriber = await GetSubscriber(context); + if (subscriber is null) { + Message = Localizer["Unsubscribe_Failure_Message"]; + return; + } + + subscriber.Unsubscribed = true; + await context.SaveChangesAsync(); + Message = Localizer["Unsubscribe_Success"]; + + var customization = Customizations.Value; + string body = TemplateService.Default( + Navigation.BaseUri, + !string.IsNullOrWhiteSpace(customization.LogoLink) ? customization.LogoLink : Navigation.ToAbsoluteUri("/img/logo.png").AbsoluteUri, + Localizer["Unsubscribe_ConfirmEmailTitle"], + Localizer["Unsubscribe_ConfirmEmailBody"]); + await EmailSender.SendEmailAsync(subscriber.Email, Localizer["ConfirmEmailSubject"], body); + await TemplateService.ValidateTokensAsync(Id!, Token!, "unsubscribe-" + Newsletter); // delete token + } catch (EmailNotSendException ex) { + Logger.LogWarning(ex, "Failed to send unsubscribe confirm email. The user has been unsubscribed anyway."); + } catch (Exception) { + Message = Localizer["Unsubscribe_Failure_Message"]; + } + } + + private async Task GetSubscriber(ApplicationDbContext? context = null) { + if (Id is null || Token is null || Newsletter is null) { + return null; + } + + var id = await TemplateService.ValidateTokensAsync(Id, Token, "unsubscribe-" + Newsletter, false); + if (id is null) { + return null; + } + + if (context is null) { + await using var context1 = await ContextFactory.CreateDbContextAsync(); + return context1.Set().FirstOrDefault(s => s.Id == id); + } + + return context.Set().FirstOrDefault(s => s.Id == id); + } +} diff --git a/Wave/Resources/Components/Pages/EmailEdit.de-DE.resx b/Wave/Resources/Components/Pages/EmailEdit.de-DE.resx new file mode 100644 index 0000000..f677a1c --- /dev/null +++ b/Wave/Resources/Components/Pages/EmailEdit.de-DE.resx @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Deabonnieren + + + Abmelden Bestätigen + + + Fehler beim laden des Abonnenten. Ihre Token sind möglicherweise inkorrekt oder abgelaufen. Bitte suchen Sie den neuesten Abmeldelink aus ihrem neuesten Newsletter heraus. If der Fehler weiterhin besteht, kontaktieren Sie den Betreiber dieser Seite. + + + Fehler beim Abmelden. Ihre Token sind möglicherweise inkorrekt oder abgelaufen. Bitte suchen Sie den neuesten Abmeldelink aus ihrem neuesten Newsletter heraus. If der Fehler weiterhin besteht, kontaktieren Sie den Betreiber dieser Seite. + + + Erfolgreich abgemeldet. Einen schönen Tag noch. + + + Erfolgreich Abgemeldet + + + Sie wurden erfolgreich vom Mailnewsletter abgemeldet + + + Sie erhalten keine weiteren E-Mail Benachrichtigungen über neue Artikel. Wir hoffen Sie waren mit unserem Service zufrieden. Falls Sie sich noch anderst entscheiden oder diese Aktion nicht von ihnen ausgeführt wurde, können Sie sich jederzeit erneut anmelden. + + \ No newline at end of file diff --git a/Wave/Resources/Components/Pages/EmailEdit.en-GB.resx b/Wave/Resources/Components/Pages/EmailEdit.en-GB.resx new file mode 100644 index 0000000..4fdb1b6 --- /dev/null +++ b/Wave/Resources/Components/Pages/EmailEdit.en-GB.resx @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Wave/Resources/Components/Pages/EmailEdit.resx b/Wave/Resources/Components/Pages/EmailEdit.resx new file mode 100644 index 0000000..d062b64 --- /dev/null +++ b/Wave/Resources/Components/Pages/EmailEdit.resx @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Unsubscribe + + + Confirm Unsubscribe + + + Failed to fetch Subscriber. Your tokens may be incorrect or expired. Please look up your unsubscribe link from the newest mail your received. If the error persists, please contact the site owner. + + + Error unsubscribing. Your tokens may be incorrect or expired. Please look up your unsubscribe link from the newest mail your received. If the error persists, please contact the site owner. + + + Successfully unsubscribed. Have a nice day. + + + You have been unsubscribed from the mailing newsletter + + + You will no longer receive mail updates about new Articles. We hope you were satisfyed with our Service. If you have changed your mind or this action was not performed by you, you can easily re-subscribe. + + + Succsessfully Unsubscribed + + \ No newline at end of file diff --git a/Wave/Services/EmailBackgroundWorker.cs b/Wave/Services/EmailBackgroundWorker.cs index e9dce56..0d10522 100644 --- a/Wave/Services/EmailBackgroundWorker.cs +++ b/Wave/Services/EmailBackgroundWorker.cs @@ -1,4 +1,5 @@ -using MailKit.Net.Smtp; +using System.Net; +using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -70,11 +71,8 @@ public class EmailBackgroundWorker(ILogger logger, IDbCon if (!string.IsNullOrWhiteSpace(Configuration.Username)) { client.Authenticate(Configuration.Username, Configuration.Password); } - - var mjmlRenderer = new MjmlRenderer(); - var options = new MjmlOptions { - Beautify = false - }; + + 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 @@ -84,35 +82,42 @@ public class EmailBackgroundWorker(ILogger logger, IDbCon string articleLink = ArticleUtilities.GenerateArticleLink( newsletter.Article, new Uri(Customizations.AppUrl, UriKind.Absolute)); - string unsubscribeLink = new Uri(new Uri(Customizations.AppUrl, UriKind.Absolute), "/unsubscribe").AbsoluteUri; string template = TemplateService.Process("newsletter", new Dictionary{ {EmailTemplateService.Constants.BrowserLink, articleLink}, {EmailTemplateService.Constants.ContentLogo, "https://blog.winter-software.com/img/logo.png"}, {EmailTemplateService.Constants.ContentTitle, newsletter.Article.Title}, {EmailTemplateService.Constants.ContentBody, newsletter.Article.BodyHtml}, - {EmailTemplateService.Constants.EmailUnsubscribeLink, unsubscribeLink} + {EmailTemplateService.Constants.EmailUnsubscribeLink, "[[<__UNSUBSCRIBE__>]]"} }); var message = new MimeMessage { From = { sender }, Subject = newsletter.Article.Title }; - var builder = new BodyBuilder { - HtmlBody = mjmlRenderer.Render(template, options).Html - }; - message.Body = builder.ToMessageBody(); EmailSubscriber? last = null; while (context.Set() - .Where(s => !s.Unsubscribed && (last == null || s.Id > last.Id)) + .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.To.Add(new MailboxAddress(subscriber.Name, subscriber.Email)); + message.Body = builder.ToMessageBody(); client.Send(message); } diff --git a/Wave/Services/EmailTemplateService.cs b/Wave/Services/EmailTemplateService.cs index 94b0f37..39faf81 100644 --- a/Wave/Services/EmailTemplateService.cs +++ b/Wave/Services/EmailTemplateService.cs @@ -29,14 +29,14 @@ public enum Constants { return (user, token); } - public async Task ValidateTokensAsync(string user, string token, string role = "subscribe") { + public async Task ValidateTokensAsync(string user, string token, string role = "subscribe", bool deleteToken = true) { string cacheKey = role + "-" + user; byte[]? tokenInCache = await TokenCache.GetAsync(cacheKey); if (tokenInCache is null || token != Convert.ToBase64String(tokenInCache)) return null; - - await TokenCache.RemoveAsync(cacheKey); + + if (deleteToken) await TokenCache.RemoveAsync(cacheKey); return new Guid(Convert.FromBase64String(user)); } diff --git a/Wave/Services/SmtpEmailSender.cs b/Wave/Services/SmtpEmailSender.cs index 53ee554..6cdcdac 100644 --- a/Wave/Services/SmtpEmailSender.cs +++ b/Wave/Services/SmtpEmailSender.cs @@ -49,11 +49,18 @@ public class SmtpEmailSender(IOptions config, ILogger