diff --git a/Wino.Core.Domain/Interfaces/IUnsubscriptionService.cs b/Wino.Core.Domain/Interfaces/IUnsubscriptionService.cs new file mode 100644 index 00000000..f066895a --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IUnsubscriptionService.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using Wino.Core.Domain.Models.Reader; + +namespace Wino.Core.Domain.Interfaces +{ + public interface IUnsubscriptionService + { + /// + /// Unsubscribes from the subscription using one-click method. + /// + /// Unsubscribtion information. + /// Whether the unsubscription is succeeded or not. + Task OneClickUnsubscribeAsync(UnsubscribeInfo info); + } +} diff --git a/Wino.Core.Domain/Models/Reader/MailRenderModel.cs b/Wino.Core.Domain/Models/Reader/MailRenderModel.cs index 0f1c47f3..918457a2 100644 --- a/Wino.Core.Domain/Models/Reader/MailRenderModel.cs +++ b/Wino.Core.Domain/Models/Reader/MailRenderModel.cs @@ -11,10 +11,9 @@ namespace Wino.Core.Domain.Models.Reader { public string RenderHtml { get; } public MailRenderingOptions MailRenderingOptions { get; } - public List Attachments { get; set; } = new List(); + public List Attachments { get; set; } = []; - public string UnsubscribeLink { get; set; } - public bool CanUnsubscribe => !string.IsNullOrEmpty(UnsubscribeLink); + public UnsubscribeInfo UnsubscribeInfo { get; set; } public MailRenderModel(string renderHtml, MailRenderingOptions mailRenderingOptions = null) { @@ -22,4 +21,12 @@ namespace Wino.Core.Domain.Models.Reader MailRenderingOptions = mailRenderingOptions; } } + + public class UnsubscribeInfo + { + public string HttpLink { get; set; } + public string MailToLink { get; set; } + public bool IsOneClick { get; set; } + public bool CanUnsubscribe => HttpLink != null || MailToLink != null; + } } diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 5de505b1..4d5fc623 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -76,6 +76,11 @@ "DialogMessage_UnlinkAccountsConfirmationTitle": "Unlink Accounts", "DialogMessage_EmptySubjectConfirmation": "Missin Subject", "DialogMessage_EmptySubjectConfirmationMessage": "Message has no subject. Do you want to continue?", + "DialogMessage_UnsubscribeConfirmationTitle": "Unsubscribe", + "DialogMessage_UnsubscribeConfirmationOneClickMessage": "Do you want to stop getting messages from {0}?", + "DialogMessage_UnsubscribeConfirmationGoToWebsiteMessage": "To stop getting messages from {0}, go to their website to unsubscribe.", + "DialogMessage_UnsubscribeConfirmationGoToWebsiteConfirmButton": "Go to website", + "DialogMessage_UnsubscribeConfirmationMailtoMessage": "Do you want to stop getting messages from {0}? Wino will unsubscribe for you by sending an email from your email account to {1}.", "Dialog_DontAskAgain": "Don't ask again", "DiscordChannelDisclaimerMessage": "Wino doesn't have it's own Discord server, but special 'wino-mail' channel is hosted at 'Developer Sanctuary' server.\nTo get the updates about Wino please join Developer Sanctuary server and follow 'wino-mail' channel under 'Community Projects'\n\nYou will be directed to server URL since Discord doesn't support channel invites.", "DiscordChannelDisclaimerTitle": "Important Discord Information", @@ -230,6 +235,8 @@ "Info_UnsupportedFunctionalityTitle": "Unsupported", "Info_UnsubscribeLinkInvalidTitle": "Invalid Unsubscribe Uri", "Info_UnsubscribeLinkInvalidMessage": "This unsubscribe link is invalid. Failed to unsubscribe from the list.", + "Info_UnsubscribeSuccessMessage": "Successfully unsubscribed from {0}.", + "Info_UnsubscribeErrorMessage": "Failed to unsubscribe", "ImapAdvancedSetupDialog_AuthenticationMethod": "Authentication method", "ImapAdvancedSetupDialog_ConnectionSecurity": "Connection security", "ImapAuthenticationMethod_Auto": "Auto", diff --git a/Wino.Core.Domain/Translator.Designer.cs b/Wino.Core.Domain/Translator.Designer.cs index 05d71230..6f905ef8 100644 --- a/Wino.Core.Domain/Translator.Designer.cs +++ b/Wino.Core.Domain/Translator.Designer.cs @@ -403,6 +403,31 @@ namespace Wino.Core.Domain /// public static string DialogMessage_EmptySubjectConfirmationMessage => Resources.GetTranslatedString(@"DialogMessage_EmptySubjectConfirmationMessage"); + /// + /// Unsubscribe + /// + public static string DialogMessage_UnsubscribeConfirmationTitle => Resources.GetTranslatedString(@"DialogMessage_UnsubscribeConfirmationTitle"); + + /// + /// Do you want to stop getting messages from {0}? + /// + public static string DialogMessage_UnsubscribeConfirmationOneClickMessage => Resources.GetTranslatedString(@"DialogMessage_UnsubscribeConfirmationOneClickMessage"); + + /// + /// To stop getting messages from {0}, go to their website to unsubscribe. + /// + public static string DialogMessage_UnsubscribeConfirmationGoToWebsiteMessage => Resources.GetTranslatedString(@"DialogMessage_UnsubscribeConfirmationGoToWebsiteMessage"); + + /// + /// Go to website + /// + public static string DialogMessage_UnsubscribeConfirmationGoToWebsiteConfirmButton => Resources.GetTranslatedString(@"DialogMessage_UnsubscribeConfirmationGoToWebsiteConfirmButton"); + + /// + /// Do you want to stop getting messages from {0}? WinoMail will unsubscribe for you by sending an email from your email account to {1}. + /// + public static string DialogMessage_UnsubscribeConfirmationMailtoMessage => Resources.GetTranslatedString(@"DialogMessage_UnsubscribeConfirmationMailtoMessage"); + /// /// Don't ask again /// @@ -1173,6 +1198,16 @@ namespace Wino.Core.Domain /// public static string Info_UnsubscribeLinkInvalidMessage => Resources.GetTranslatedString(@"Info_UnsubscribeLinkInvalidMessage"); + /// + /// Successfully unsubscribed from {0}. + /// + public static string Info_UnsubscribeSuccessMessage => Resources.GetTranslatedString(@"Info_UnsubscribeSuccessMessage"); + + /// + /// Failed to unsubscribe + /// + public static string Info_UnsubscribeErrorMessage => Resources.GetTranslatedString(@"Info_UnsubscribeErrorMessage"); + /// /// Authentication method /// diff --git a/Wino.Core/CoreContainerSetup.cs b/Wino.Core/CoreContainerSetup.cs index 0554b0a0..67f4c2ce 100644 --- a/Wino.Core/CoreContainerSetup.cs +++ b/Wino.Core/CoreContainerSetup.cs @@ -37,6 +37,7 @@ namespace Wino.Core services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Wino.Core/Services/MimeFileService.cs b/Wino.Core/Services/MimeFileService.cs index 03cb14da..02768349 100644 --- a/Wino.Core/Services/MimeFileService.cs +++ b/Wino.Core/Services/MimeFileService.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using MimeKit; @@ -221,22 +222,24 @@ namespace Wino.Core.Services } } - // Check for List-Unsubscribe link if possible. - if (message.Headers.Contains(HeaderId.ListUnsubscribe)) { - renderingModel.UnsubscribeLink = message.Headers[HeaderId.ListUnsubscribe].Normalize(); + var unsubscribeLinks = message.Headers[HeaderId.ListUnsubscribe] + .Normalize() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim([' ', '<', '>'])); - // Sometimes this link is wrapped with < >, remove them. - if (renderingModel.UnsubscribeLink.StartsWith("<")) + // Only two types of unsubscribe links are possible. + // So each has it's own property to simplify the usage. + renderingModel.UnsubscribeInfo = new UnsubscribeInfo() { - renderingModel.UnsubscribeLink = renderingModel.UnsubscribeLink.Substring(1, renderingModel.UnsubscribeLink.Length - 2); - } + HttpLink = unsubscribeLinks.FirstOrDefault(x => x.StartsWith("http", StringComparison.OrdinalIgnoreCase)), + MailToLink = unsubscribeLinks.FirstOrDefault(x => x.StartsWith("mailto", StringComparison.OrdinalIgnoreCase)), + IsOneClick = message.Headers.Contains(HeaderId.ListUnsubscribePost) + }; } return renderingModel; } - - } } diff --git a/Wino.Core/Services/UnsubscriptionService.cs b/Wino.Core/Services/UnsubscriptionService.cs new file mode 100644 index 00000000..c9f7225d --- /dev/null +++ b/Wino.Core/Services/UnsubscriptionService.cs @@ -0,0 +1,36 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Serilog; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Reader; + +namespace Wino.Core.Services +{ + public class UnsubscriptionService : IUnsubscriptionService + { + public async Task OneClickUnsubscribeAsync(UnsubscribeInfo info) + { + try + { + using var httpClient = new HttpClient(); + + var unsubscribeRequest = new HttpRequestMessage(HttpMethod.Post, info.HttpLink) + { + Content = new StringContent("List-Unsubscribe=One-Click", Encoding.UTF8, "application/x-www-form-urlencoded") + }; + + var result = await httpClient.SendAsync(unsubscribeRequest).ConfigureAwait(false); + + return result.IsSuccessStatusCode; + } + catch (Exception ex) + { + Log.Error("Failed to unsubscribe from {HttpLink} - {Message}", info.HttpLink, ex.Message); + } + + return false; + } + } +} diff --git a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs index eaba467c..df3dac28 100644 --- a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs @@ -38,6 +38,7 @@ namespace Wino.Mail.ViewModels private readonly IWinoSynchronizerFactory _winoSynchronizerFactory; private readonly IWinoRequestDelegator _requestDelegator; private readonly IClipboardService _clipboardService; + private readonly IUnsubscriptionService _unsubscriptionService; private bool forceImageLoading = false; @@ -47,7 +48,7 @@ namespace Wino.Mail.ViewModels #region Properties public bool ShouldDisplayDownloadProgress => IsIndetermineProgress || (CurrentDownloadPercentage > 0 && CurrentDownloadPercentage <= 100); - public bool CanUnsubscribe => CurrentRenderModel?.CanUnsubscribe ?? false; + public bool CanUnsubscribe => CurrentRenderModel?.UnsubscribeInfo?.CanUnsubscribe ?? false; public bool IsJunkMail => initializedMailItemViewModel?.AssignedFolder != null && initializedMailItemViewModel.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk; public bool IsImageRenderingDisabled @@ -125,6 +126,7 @@ namespace Wino.Mail.ViewModels IWinoRequestDelegator requestDelegator, IStatePersistanceService statePersistanceService, IClipboardService clipboardService, + IUnsubscriptionService unsubscriptionService, IPreferencesService preferencesService) : base(dialogService) { NativeAppService = nativeAppService; @@ -132,6 +134,7 @@ namespace Wino.Mail.ViewModels PreferencesService = preferencesService; _clipboardService = clipboardService; + _unsubscriptionService = unsubscriptionService; _underlyingThemeService = underlyingThemeService; _mimeFileService = mimeFileService; _mailService = mailService; @@ -172,18 +175,55 @@ namespace Wino.Mail.ViewModels [RelayCommand] private async Task UnsubscribeAsync() { - if (!CurrentRenderModel?.CanUnsubscribe ?? false) return; + if (!(CurrentRenderModel?.UnsubscribeInfo?.CanUnsubscribe ?? false)) return; - // TODO: Support for List-Unsubscribe-Post header. It can be done without launching browser. - // https://certified-senders.org/wp-content/uploads/2017/07/CSA_one-click_list-unsubscribe.pdf + bool confirmed; - // TODO: Sometimes unsubscribe link can be a mailto: link. - // or sometimes with mailto AND http link. We need to handle this. + // Try to unsubscribe by http first. + if (CurrentRenderModel.UnsubscribeInfo.HttpLink is not null) + { + if (!Uri.IsWellFormedUriString(CurrentRenderModel.UnsubscribeInfo.HttpLink, UriKind.RelativeOrAbsolute)) + { + DialogService.InfoBarMessage(Translator.Info_UnsubscribeLinkInvalidTitle, Translator.Info_UnsubscribeLinkInvalidMessage, InfoBarMessageType.Error); + return; + } - if (Uri.IsWellFormedUriString(CurrentRenderModel.UnsubscribeLink, UriKind.RelativeOrAbsolute)) - await NativeAppService.LaunchUriAsync(new Uri((CurrentRenderModel.UnsubscribeLink))); - else - DialogService.InfoBarMessage(Translator.Info_UnsubscribeLinkInvalidTitle, Translator.Info_UnsubscribeLinkInvalidMessage, InfoBarMessageType.Error); + // Support for List-Unsubscribe-Post header. It can be done without launching browser. + // https://datatracker.ietf.org/doc/html/rfc8058 + if (CurrentRenderModel.UnsubscribeInfo.IsOneClick) + { + confirmed = await DialogService.ShowConfirmationDialogAsync(string.Format(Translator.DialogMessage_UnsubscribeConfirmationOneClickMessage, FromName), Translator.DialogMessage_UnsubscribeConfirmationTitle, Translator.Unsubscribe); + if (!confirmed) return; + + bool isOneClickUnsubscribed = await _unsubscriptionService.OneClickUnsubscribeAsync(CurrentRenderModel.UnsubscribeInfo); + + if (isOneClickUnsubscribed) + { + DialogService.InfoBarMessage(Translator.Unsubscribe, string.Format(Translator.Info_UnsubscribeSuccessMessage, FromName), InfoBarMessageType.Success); + } + else + { + DialogService.InfoBarMessage(Translator.GeneralTitle_Error, Translator.Info_UnsubscribeErrorMessage, InfoBarMessageType.Error); + } + } + else + { + confirmed = await DialogService.ShowConfirmationDialogAsync(string.Format(Translator.DialogMessage_UnsubscribeConfirmationGoToWebsiteMessage, FromName), Translator.DialogMessage_UnsubscribeConfirmationTitle, Translator.DialogMessage_UnsubscribeConfirmationGoToWebsiteConfirmButton); + if (!confirmed) return; + + await NativeAppService.LaunchUriAsync(new Uri(CurrentRenderModel.UnsubscribeInfo.HttpLink)); + } + } + else if (CurrentRenderModel.UnsubscribeInfo.MailToLink is not null) + { + confirmed = await DialogService.ShowConfirmationDialogAsync(string.Format(Translator.DialogMessage_UnsubscribeConfirmationMailtoMessage, FromName, new string(CurrentRenderModel.UnsubscribeInfo.MailToLink.Skip(7).ToArray())), Translator.DialogMessage_UnsubscribeConfirmationTitle, Translator.Unsubscribe); + + if (!confirmed) return; + + // TODO: Implement automatic mail send after user confirms the action. + // Currently it will launch compose page and user should manually press send button. + await NativeAppService.LaunchUriAsync(new Uri(CurrentRenderModel.UnsubscribeInfo.MailToLink)); + } } [RelayCommand]