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]