beb3bf9d1d
* feat: add S/MIME certificate management - Introduced `ISmimeCertificateService` interface for managing S/MIME certificates. - Implemented `SmimeCertificateService` class to handle certificate operations. - Updated `WinoPage` enum to include `SignatureAndEncryptionPage`. - Added resource entries in `resources.json` for S/MIME related messages. - Created `SignatureAndEncryptionPage` view and logic for user interaction. - Modified configuration files to integrate the new service and page. - Updated project files to include necessary dependencies for certificate management. * refactor(SmimeCertificateService): ♻️ Use constant for certificate name Refactored the `SmimeCertificateService` to replace the hardcoded string "Wino Mail Certificate" with a constant `CertificateFriendlyName`. This change enhances code maintainability by centralizing the definition of the certificate's friendly name. • Introduced a constant for the certificate's friendly name. • Updated the certificate retrieval and import logic to use the new constant. * feat(alias): ✨ Add S/Mime certificate selection for every alias Added new properties and methods in `MailAccountAlias` to manage signing and encryption certificates, including their thumbprints. This enhancement allows for better handling of S/Mime certificates within the application. • Introduced new properties for signing and encryption certificates. • Updated `resources.json` with new translations for S/Mime certificates. • Enhanced `AliasManagementPageViewModel` to include a dependency on the S/Mime certificate service and updated alias loading methods. • Modified `AliasManagementPage.xaml` to include ComboBox controls for selecting certificates. • Implemented methods in `AliasManagementPage.xaml.cs` to handle certificate selection from dropdowns. This change improves the user experience by allowing users to select and manage their S/Mime certificates directly within the alias management interface. * feat(mail): ✨ Add S/MIME support and file picker updates Enhanced the `MailRenderModel` class by adding a new property `IsSmimeSigned` to indicate if an email is S/MIME signed. The constructor has been updated to accept `MailRenderingOptions`. Updated the file selection logic in `DialogServiceBase` to replace the `FolderPicker` with a `FileSavePicker`, streamlining the process of saving files. Removed unnecessary commented code and added logic to handle file extensions. In `MailRenderingPageViewModel`, a new property `IsSmimeSigned` reflects the S/MIME status of the current render model, along with a new method `ShowSmimeCertificateInfoAsync` to display S/MIME certificate details. Added a `HyperlinkButton` in `MailRenderingPage.xaml` to indicate S/MIME status, which is only visible for signed emails, providing a tooltip and command for more information. In `MimeFileService`, implemented logic to detect S/MIME signatures in messages and exclude S/MIME signature parts from attachments. * refactor(viewmodel): ♻️ Replace dialog service messages Refactored the `SignatureAndEncryptionPageViewModel.cs` to replace calls to `_dialogService.ShowMessageAsync` with `_dialogService.InfoBarMessage`. This change improves the handling of success messages during certificate import and removal processes. * feat(mail): ✨ Add S/MIME encryption indicator Implemented support for S/MIME email handling in the MailRenderingPageViewModel. This includes the addition of a new property to check if an email is encrypted and updates to methods for displaying S/MIME certificate information. A new column was added in the MailRenderingPage.xaml to indicate if an email is encrypted, along with updated tooltips and commands. The MimeFileService was also modified to detect S/MIME encryption and to exclude S/MIME signature certificates during attachment processing. * fix: Added missing property * feat: Added S/Mime decryption and signing verification and improvements * i18n(resources): 🌐 Add S/MIME translation strings Added new translation strings for S/MIME functionalities in `resources.json`, including messages for signatures and certificates in both English and Italian. The code has been updated to utilize these new translation strings, enhancing the application's internationalization. Updated `MailRenderingPageViewModel.cs` to use the new translation strings for signature and certificate messages, improving code readability and consistency with translations. Additionally, the tooltips for S/MIME signing and encryption buttons in `MailRenderingPage.xaml` have been updated to use the new translation strings, enhancing the user experience for Italian-speaking users. * fix: Extract body from MultipartSigned message * feat(smime): ✨ Enhance S/MIME certificate handling Updated the `SmimeCertificateService` to improve the loading of PKCS12 certificate collections by adding `X509KeyStorageFlags.DefaultKeySet` and `X509KeyStorageFlags.Exportable` for better key management. In `ComposePageViewModel`, imported necessary namespaces for S/MIME certificate handling and added a new dependency for `ISmimeCertificateService`. Implemented logic in `OpenAttachmentAsync` to load alias certificates and manage message signing and encryption based on user-selected certificates. This change enhances the security and flexibility of email handling within the application. * feat: Replaced Smime encryption certificate combobox with checkbox Cert selection is useless for encryption * feat: Added S/Mime togglebuttons when composing an email * i18n(translations): 🌐 Add new composer translations Added new translation strings for composer features, including themes, text formatting, and S/MIME signing and encryption options. Updated button labels to utilize these new strings, enhancing the application's internationalization. Additionally, removed an obsolete string related to S/MIME certificate file information. * Example for relay command and fix settings pages runtime error * refactor(viewmodel): ♻️ Update certificate import/export commands Refactored the certificate import and export commands in the `SignatureAndEncryptionPageViewModel`. Changed methods from `async void` to `async Task` for better error handling and tracking of asynchronous operations. Added `[RelayCommand]` attributes to improve adherence to the MVVM pattern. Updated the XAML file to bind buttons directly to the new command methods, removing the need for event handlers. This enhances separation of concerns and simplifies the code. Removed obsolete event handlers from the code-behind file, streamlining the implementation. * fix: export folderPath parameter contains file name * fix: QRESYNC initial modseq should be 1 (#734) * Fix typo in reorder accounts dialog (#754) * fix: Missing commas in translations files * fix: merge issues * Fix mege conflicts. * Some more conflict fixes. * Fixing context. * Fixing saving file with suggested file name. --------- Co-authored-by: Aleh Khantsevich <aleh.khantsevich@gmail.com> Co-authored-by: Konstantin Shkel <null+github@pcho.la> Co-authored-by: Cas Cornelissen <cas.cornelissen@onefinity.io> Co-authored-by: Burak Kaan Köse <bkaankose@outlook.com>
894 lines
35 KiB
C#
894 lines
35 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using MailKit;
|
|
|
|
using MimeKit;
|
|
using MimeKit.Cryptography;
|
|
using Serilog;
|
|
using Wino.Core.Domain;
|
|
using Wino.Core.Domain.Entities.Mail;
|
|
using Wino.Core.Domain.Entities.Shared;
|
|
using Wino.Core.Domain.Enums;
|
|
using Wino.Core.Domain.Interfaces;
|
|
using Wino.Core.Domain.Models.MailItem;
|
|
using Wino.Core.Domain.Models.Menus;
|
|
using Wino.Core.Domain.Models.Navigation;
|
|
using Wino.Core.Domain.Models.Printing;
|
|
using Wino.Core.Domain.Models.Reader;
|
|
using Wino.Core.Services;
|
|
using Wino.Mail.ViewModels.Data;
|
|
using Wino.Mail.ViewModels.Messages;
|
|
using Wino.Messaging.Client.Mails;
|
|
using Wino.Messaging.UI;
|
|
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
|
|
|
namespace Wino.Mail.ViewModels;
|
|
|
|
public partial class MailRenderingPageViewModel : MailBaseViewModel,
|
|
IRecipient<NewMailItemRenderingRequestedEvent>,
|
|
IRecipient<ThumbnailAdded>,
|
|
ITransferProgress // For listening IMAP message download progress.
|
|
{
|
|
private readonly IMailDialogService _dialogService;
|
|
private readonly IUnderlyingThemeService _underlyingThemeService;
|
|
|
|
private readonly IMimeFileService _mimeFileService;
|
|
private readonly Core.Domain.Interfaces.IMailService _mailService;
|
|
private readonly IFileService _fileService;
|
|
private readonly IWinoRequestDelegator _requestDelegator;
|
|
private readonly IContactService _contactService;
|
|
private readonly IClipboardService _clipboardService;
|
|
private readonly IUnsubscriptionService _unsubscriptionService;
|
|
private readonly IApplicationConfiguration _applicationConfiguration;
|
|
private bool forceImageLoading = false;
|
|
|
|
private MailItemViewModel initializedMailItemViewModel = null;
|
|
private MimeMessageInformation initializedMimeMessageInformation = null;
|
|
|
|
// Func to get WebView2 to save current HTML as PDF to given location.
|
|
// Used in 'Save as' and 'Print' functionality.
|
|
public Func<string, Task<bool>> SaveHTMLasPDFFunc { get; set; }
|
|
|
|
public Func<WebView2PrintSettingsModel, Task<PrintingResult>> DirectPrintFuncAsync { get; set; }
|
|
|
|
#region Properties
|
|
|
|
public bool ShouldDisplayDownloadProgress => IsIndetermineProgress || (CurrentDownloadPercentage > 0 && CurrentDownloadPercentage <= 100);
|
|
public bool CanUnsubscribe => CurrentRenderModel?.UnsubscribeInfo?.CanUnsubscribe ?? false;
|
|
public bool IsSmimeSigned => (CurrentRenderModel?.Signatures?.Count ?? 0) > 0;
|
|
public bool IsSmimeEncrypted => CurrentRenderModel?.IsSmimeEncrypted ?? false;
|
|
public bool IsJunkMail => initializedMailItemViewModel?.MailCopy.AssignedFolder != null && initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk;
|
|
public bool SmimeSignaturesValid => CurrentRenderModel?.Signatures?.Any(x => x.Value) ?? false;
|
|
public bool SmimeSignaturesInvalid => !SmimeSignaturesValid;
|
|
|
|
public bool IsImageRenderingDisabled
|
|
{
|
|
get
|
|
{
|
|
if (IsJunkMail)
|
|
{
|
|
return !forceImageLoading;
|
|
}
|
|
else
|
|
{
|
|
return !CurrentRenderModel?.MailRenderingOptions?.LoadImages ?? false;
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool isDarkWebviewRenderer;
|
|
public bool IsDarkWebviewRenderer
|
|
{
|
|
get => isDarkWebviewRenderer;
|
|
set
|
|
{
|
|
if (SetProperty(ref isDarkWebviewRenderer, value))
|
|
{
|
|
InitializeCommandBarItems();
|
|
}
|
|
}
|
|
}
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(ShouldDisplayDownloadProgress))]
|
|
public partial bool IsIndetermineProgress { get; set; }
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(ShouldDisplayDownloadProgress))]
|
|
public partial double CurrentDownloadPercentage { get; set; }
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(CanUnsubscribe))]
|
|
[NotifyPropertyChangedFor(nameof(IsSmimeSigned))]
|
|
[NotifyPropertyChangedFor(nameof(IsSmimeEncrypted))]
|
|
[NotifyPropertyChangedFor(nameof(SmimeSignaturesValid))]
|
|
[NotifyPropertyChangedFor(nameof(SmimeSignaturesInvalid))]
|
|
public partial MailRenderModel CurrentRenderModel { get; set; }
|
|
|
|
[ObservableProperty]
|
|
public partial string Subject { get; set; }
|
|
|
|
[ObservableProperty]
|
|
public partial string FromAddress { get; set; }
|
|
|
|
[ObservableProperty]
|
|
public partial string FromName { get; set; }
|
|
|
|
[ObservableProperty]
|
|
public partial string ContactPicture { get; set; }
|
|
|
|
[ObservableProperty]
|
|
public partial DateTime CreationDate { get; set; }
|
|
public ObservableCollection<AccountContactViewModel> ToItems { get; set; } = [];
|
|
public ObservableCollection<AccountContactViewModel> CcItems { get; set; } = [];
|
|
public ObservableCollection<AccountContactViewModel> BccItems { get; set; } = [];
|
|
public ObservableCollection<MailAttachmentViewModel> Attachments { get; set; } = [];
|
|
public ObservableCollection<MailOperationMenuItem> MenuItems { get; set; } = [];
|
|
|
|
#endregion
|
|
|
|
public INativeAppService NativeAppService { get; }
|
|
public IStatePersistanceService StatePersistenceService { get; }
|
|
public IPreferencesService PreferencesService { get; }
|
|
public IPrintService PrintService { get; }
|
|
|
|
public MailRenderingPageViewModel(IMailDialogService dialogService,
|
|
INativeAppService nativeAppService,
|
|
IUnderlyingThemeService underlyingThemeService,
|
|
IMimeFileService mimeFileService,
|
|
IMailService mailService,
|
|
IFileService fileService,
|
|
IWinoRequestDelegator requestDelegator,
|
|
IStatePersistanceService statePersistenceService,
|
|
IContactService contactService,
|
|
IClipboardService clipboardService,
|
|
IUnsubscriptionService unsubscriptionService,
|
|
IPreferencesService preferencesService,
|
|
IPrintService printService,
|
|
IApplicationConfiguration applicationConfiguration)
|
|
{
|
|
_dialogService = dialogService;
|
|
NativeAppService = nativeAppService;
|
|
StatePersistenceService = statePersistenceService;
|
|
_contactService = contactService;
|
|
PreferencesService = preferencesService;
|
|
PrintService = printService;
|
|
_applicationConfiguration = applicationConfiguration;
|
|
_clipboardService = clipboardService;
|
|
_unsubscriptionService = unsubscriptionService;
|
|
_underlyingThemeService = underlyingThemeService;
|
|
_mimeFileService = mimeFileService;
|
|
_mailService = mailService;
|
|
_fileService = fileService;
|
|
_requestDelegator = requestDelegator;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task CopyClipboard(string copyText)
|
|
{
|
|
try
|
|
{
|
|
await _clipboardService.CopyClipboardAsync(copyText);
|
|
|
|
_dialogService.InfoBarMessage(Translator.ClipboardTextCopied_Title, string.Format(Translator.ClipboardTextCopied_Message, copyText), InfoBarMessageType.Information);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, string.Format(Translator.ClipboardTextCopyFailed_Message, copyText), InfoBarMessageType.Error);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ForceImageLoading()
|
|
{
|
|
if (initializedMailItemViewModel == null && initializedMimeMessageInformation == null) return;
|
|
|
|
await RenderAsync(initializedMimeMessageInformation, ignoreJunkFilter: true);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task UnsubscribeAsync()
|
|
{
|
|
if (!(CurrentRenderModel?.UnsubscribeInfo?.CanUnsubscribe ?? false)) return;
|
|
|
|
bool confirmed;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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]
|
|
private async Task OperationClicked(MailOperationMenuItem menuItem)
|
|
{
|
|
if (menuItem == null) return;
|
|
|
|
await HandleMailOperationAsync(menuItem.Operation);
|
|
}
|
|
|
|
private async Task HandleMailOperationAsync(MailOperation operation)
|
|
{
|
|
// Toggle theme
|
|
if (operation == MailOperation.DarkEditor || operation == MailOperation.LightEditor)
|
|
IsDarkWebviewRenderer = !IsDarkWebviewRenderer;
|
|
else if (operation == MailOperation.SaveAs)
|
|
{
|
|
await SaveAsAsync();
|
|
}
|
|
else if (operation == MailOperation.Print)
|
|
{
|
|
var settings = await _dialogService.ShowPrintDialogAsync();
|
|
|
|
if (settings == null) return;
|
|
|
|
var printingResult = await DirectPrintFuncAsync.Invoke(settings);
|
|
|
|
// TODO: More detailed printing result handling.
|
|
if (printingResult == PrintingResult.Submitted)
|
|
{
|
|
_dialogService.InfoBarMessage(Translator.DialogMessage_PrintingSuccessTitle, Translator.DialogMessage_PrintingSuccessMessage, InfoBarMessageType.Success);
|
|
}
|
|
else if (printingResult == PrintingResult.Failed)
|
|
{
|
|
_dialogService.InfoBarMessage(Translator.DialogMessage_PrintingFailedTitle, Translator.DialogMessage_PrintingFailedMessage, InfoBarMessageType.Error);
|
|
}
|
|
|
|
}
|
|
else if (operation == MailOperation.ViewMessageSource)
|
|
{
|
|
await _dialogService.ShowMessageSourceDialogAsync(initializedMimeMessageInformation.MimeMessage.ToString());
|
|
}
|
|
else if (operation == MailOperation.Reply || operation == MailOperation.ReplyAll || operation == MailOperation.Forward)
|
|
{
|
|
if (initializedMailItemViewModel == null) return;
|
|
|
|
// Create new draft.
|
|
var draftOptions = new DraftCreationOptions()
|
|
{
|
|
Reason = operation switch
|
|
{
|
|
MailOperation.Reply => DraftCreationReason.Reply,
|
|
MailOperation.ReplyAll => DraftCreationReason.ReplyAll,
|
|
MailOperation.Forward => DraftCreationReason.Forward,
|
|
_ => DraftCreationReason.Empty
|
|
},
|
|
ReferencedMessage = new ReferencedMessage()
|
|
{
|
|
MimeMessage = initializedMimeMessageInformation.MimeMessage,
|
|
MailCopy = initializedMailItemViewModel.MailCopy
|
|
}
|
|
};
|
|
|
|
var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(initializedMailItemViewModel.MailCopy.AssignedAccount.Id, draftOptions).ConfigureAwait(false);
|
|
|
|
var draftPreparationRequest = new DraftPreparationRequest(initializedMailItemViewModel.MailCopy.AssignedAccount, draftMailCopy, draftBase64MimeMessage, draftOptions.Reason, initializedMailItemViewModel.MailCopy);
|
|
|
|
await _requestDelegator.ExecuteAsync(draftPreparationRequest);
|
|
|
|
}
|
|
else if (initializedMailItemViewModel != null)
|
|
{
|
|
// All other operations require a mail item.
|
|
var prepRequest = new MailOperationPreperationRequest(operation, initializedMailItemViewModel.MailCopy);
|
|
await _requestDelegator.ExecuteAsync(prepRequest);
|
|
}
|
|
}
|
|
|
|
private CancellationTokenSource renderCancellationTokenSource = new CancellationTokenSource();
|
|
|
|
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
|
{
|
|
base.OnNavigatedTo(mode, parameters);
|
|
|
|
renderCancellationTokenSource.Cancel();
|
|
|
|
initializedMailItemViewModel = null;
|
|
initializedMimeMessageInformation = null;
|
|
|
|
// Dispose existing content first.
|
|
Messenger.Send(new CancelRenderingContentRequested());
|
|
|
|
// This page can be accessed for 2 purposes.
|
|
// 1. Rendering a mail item when the user selects.
|
|
// 2. Rendering an existing EML file with MimeMessage.
|
|
|
|
// MimeMessage rendering must be readonly and no command bar items must be shown except common
|
|
// items like dark/light editor, zoom, print etc.
|
|
|
|
// Configure common rendering properties first.
|
|
IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark();
|
|
|
|
renderCancellationTokenSource = new CancellationTokenSource();
|
|
|
|
// Mime content might not be available for now and might require a download.
|
|
try
|
|
{
|
|
if (parameters is MailItemViewModel selectedMailItemViewModel)
|
|
await RenderAsync(selectedMailItemViewModel, renderCancellationTokenSource.Token);
|
|
else if (parameters is MimeMessageInformation mimeMessageInformation)
|
|
await RenderAsync(mimeMessageInformation);
|
|
|
|
InitializeCommandBarItems();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Log.Information("Canceled mail rendering.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_dialogService.InfoBarMessage(Translator.Info_MailRenderingFailedTitle, string.Format(Translator.Info_MailRenderingFailedMessage, ex.Message), InfoBarMessageType.Error);
|
|
|
|
Log.Error(ex, "Failed to render mail.");
|
|
}
|
|
}
|
|
|
|
private async Task HandleSingleItemDownloadAsync(MailItemViewModel mailItemViewModel)
|
|
{
|
|
try
|
|
{
|
|
// To show the progress on the UI.
|
|
CurrentDownloadPercentage = 1;
|
|
|
|
// Download missing MIME message using SynchronizationManager
|
|
await SynchronizationManager.Instance.DownloadMimeMessageAsync(
|
|
mailItemViewModel.MailCopy,
|
|
mailItemViewModel.MailCopy.AssignedAccount.Id);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Log.Information("MIME download is canceled.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, ex.Message, InfoBarMessageType.Error);
|
|
}
|
|
finally
|
|
{
|
|
ResetProgress();
|
|
}
|
|
}
|
|
|
|
private async Task RenderAsync(MailItemViewModel mailItemViewModel, CancellationToken cancellationToken = default)
|
|
{
|
|
ResetProgress();
|
|
var isMimeExists = await _mimeFileService.IsMimeExistAsync(mailItemViewModel.MailCopy.AssignedAccount.Id, mailItemViewModel.MailCopy.FileId);
|
|
|
|
if (!isMimeExists)
|
|
{
|
|
await HandleSingleItemDownloadAsync(mailItemViewModel);
|
|
}
|
|
|
|
// Find the MIME for this item and render it.
|
|
var mimeMessageInformation = await _mimeFileService.GetMimeMessageInformationAsync(mailItemViewModel.MailCopy.FileId,
|
|
mailItemViewModel.MailCopy.AssignedAccount.Id,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (mimeMessageInformation == null)
|
|
{
|
|
_dialogService.InfoBarMessage(Translator.Info_MessageCorruptedTitle, Translator.Info_MessageCorruptedMessage, InfoBarMessageType.Error);
|
|
return;
|
|
}
|
|
|
|
initializedMailItemViewModel = mailItemViewModel;
|
|
await RenderAsync(mimeMessageInformation);
|
|
}
|
|
|
|
private async Task RenderAsync(MimeMessageInformation mimeMessageInformation, bool ignoreJunkFilter = false)
|
|
{
|
|
forceImageLoading = ignoreJunkFilter;
|
|
|
|
var message = mimeMessageInformation.MimeMessage;
|
|
var messagePath = mimeMessageInformation.Path;
|
|
|
|
initializedMimeMessageInformation = mimeMessageInformation;
|
|
|
|
// TODO: Handle S/MIME decryption.
|
|
// initializedMimeMessageInformation.MimeMessage.Body is MultipartSigned
|
|
|
|
var renderingOptions = PreferencesService.GetRenderingOptions();
|
|
|
|
// Prepare account contacts info in advance, to avoid UI shifts after clearing collections.
|
|
var toAccountContacts = await GetAccountContacts(message.To);
|
|
var ccAccountContacts = await GetAccountContacts(message.Cc);
|
|
var bccAccountContacts = await GetAccountContacts(message.Bcc);
|
|
|
|
await ExecuteUIThread(() =>
|
|
{
|
|
Attachments.Clear();
|
|
ToItems.Clear();
|
|
CcItems.Clear();
|
|
BccItems.Clear();
|
|
|
|
foreach (var item in toAccountContacts)
|
|
ToItems.Add(item);
|
|
foreach (var item in ccAccountContacts)
|
|
CcItems.Add(item);
|
|
foreach (var item in bccAccountContacts)
|
|
BccItems.Add(item);
|
|
|
|
Subject = string.IsNullOrWhiteSpace(message.Subject) ? Translator.MailItemNoSubject : message.Subject;
|
|
|
|
// TODO: FromName and FromAddress is probably not correct here for mail lists.
|
|
FromAddress = message.From.Mailboxes.FirstOrDefault()?.Address ?? Translator.UnknownAddress;
|
|
FromName = message.From.Mailboxes.FirstOrDefault()?.Name ?? Translator.UnknownSender;
|
|
|
|
// Use the received date from MailCopy if available, otherwise fall back to the sent date from MIME message
|
|
CreationDate = initializedMailItemViewModel?.MailCopy.CreationDate ?? message.Date.DateTime;
|
|
|
|
ContactPicture = initializedMailItemViewModel?.MailCopy.SenderContact?.Base64ContactPicture;
|
|
|
|
// Automatically disable images for Junk folder to prevent pixel tracking.
|
|
// This can only work for selected mail item rendering, not for EML file rendering.
|
|
if (initializedMailItemViewModel != null &&
|
|
initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk)
|
|
{
|
|
renderingOptions.LoadImages = false;
|
|
}
|
|
|
|
// Load images if forced.
|
|
if (ignoreJunkFilter)
|
|
{
|
|
renderingOptions.LoadImages = true;
|
|
}
|
|
|
|
CurrentRenderModel = _mimeFileService.GetMailRenderModel(message, messagePath, renderingOptions);
|
|
|
|
Messenger.Send(new HtmlRenderingRequested(CurrentRenderModel.RenderHtml));
|
|
|
|
foreach (var attachment in CurrentRenderModel.Attachments)
|
|
{
|
|
Attachments.Add(new MailAttachmentViewModel(attachment));
|
|
}
|
|
|
|
OnPropertyChanged(nameof(IsImageRenderingDisabled));
|
|
|
|
StatePersistenceService.IsReadingMail = true;
|
|
});
|
|
}
|
|
|
|
private async Task<List<AccountContactViewModel>> GetAccountContacts(InternetAddressList internetAddresses)
|
|
{
|
|
List<AccountContactViewModel> accounts = [];
|
|
foreach (var item in internetAddresses)
|
|
{
|
|
if (item is MailboxAddress mailboxAddress)
|
|
{
|
|
var foundContact = await _contactService.GetAddressInformationByAddressAsync(mailboxAddress.Address).ConfigureAwait(false)
|
|
?? new AccountContact() { Name = mailboxAddress.Name, Address = mailboxAddress.Address };
|
|
|
|
var contactViewModel = new AccountContactViewModel(foundContact);
|
|
|
|
// Make sure that user account first in the list.
|
|
if (string.Equals(contactViewModel.Address, initializedMailItemViewModel?.MailCopy.AssignedAccount?.Address, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
contactViewModel.IsMe = true;
|
|
accounts.Insert(0, contactViewModel);
|
|
}
|
|
else
|
|
{
|
|
accounts.Add(contactViewModel);
|
|
}
|
|
}
|
|
else if (item is GroupAddress groupAddress)
|
|
{
|
|
accounts.AddRange(await GetAccountContacts(groupAddress.Members));
|
|
}
|
|
}
|
|
|
|
if (accounts.Count > 0)
|
|
accounts[^1].IsSemicolon = false;
|
|
|
|
return accounts;
|
|
}
|
|
|
|
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
|
{
|
|
base.OnNavigatedFrom(mode, parameters);
|
|
|
|
renderCancellationTokenSource.Cancel();
|
|
CurrentDownloadPercentage = 0d;
|
|
|
|
initializedMailItemViewModel = null;
|
|
initializedMimeMessageInformation = null;
|
|
|
|
forceImageLoading = false;
|
|
|
|
StatePersistenceService.IsReadingMail = false;
|
|
}
|
|
|
|
private void ResetProgress()
|
|
{
|
|
CurrentDownloadPercentage = 0;
|
|
IsIndetermineProgress = false;
|
|
}
|
|
|
|
private void InitializeCommandBarItems()
|
|
{
|
|
MenuItems.Clear();
|
|
|
|
// Add light/dark editor theme switch.
|
|
if (IsDarkWebviewRenderer)
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.LightEditor));
|
|
else
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.DarkEditor));
|
|
|
|
// Save As PDF
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.SaveAs, true, true));
|
|
|
|
// Print
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Print, true, true));
|
|
|
|
if (initializedMailItemViewModel == null)
|
|
return;
|
|
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Seperator));
|
|
|
|
// You can't do these to draft items.
|
|
if (!initializedMailItemViewModel.IsDraft)
|
|
{
|
|
// Reply
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Reply));
|
|
|
|
// Reply All
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.ReplyAll));
|
|
|
|
// Forward
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Forward));
|
|
}
|
|
|
|
if (initializedMimeMessageInformation?.MimeMessage != null)
|
|
{
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.ViewMessageSource, true, true));
|
|
}
|
|
|
|
// Archive - Unarchive
|
|
if (initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive)
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.UnArchive));
|
|
else
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Archive));
|
|
|
|
// Delete
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.SoftDelete));
|
|
|
|
// Flag - Clear Flag
|
|
if (initializedMailItemViewModel.IsFlagged)
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.ClearFlag));
|
|
else
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.SetFlag));
|
|
|
|
// Secondary items.
|
|
|
|
// Read - Unread
|
|
if (initializedMailItemViewModel.IsRead)
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsUnread, true, false));
|
|
else
|
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false));
|
|
}
|
|
|
|
protected override async void OnMailUpdated(MailCopy updatedMail)
|
|
{
|
|
base.OnMailUpdated(updatedMail);
|
|
|
|
if (initializedMailItemViewModel == null) return;
|
|
|
|
// Check if the updated mail is the same mail item we are rendering.
|
|
// This is done with UniqueId to include FolderId into calculations.
|
|
if (initializedMailItemViewModel.MailCopy.UniqueId != updatedMail.UniqueId) return;
|
|
|
|
// Mail operation might change the mail item like mark read/unread or change flag.
|
|
// So we need to update the mail item view model when this happens.
|
|
// Also command bar items must be re-initialized since the items loaded based on the mail item.
|
|
|
|
await ExecuteUIThread(() => { InitializeCommandBarItems(); });
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task OpenAttachmentAsync(MailAttachmentViewModel attachmentViewModel)
|
|
{
|
|
try
|
|
{
|
|
var fileFolderPath = Path.Combine(initializedMimeMessageInformation.Path, attachmentViewModel.FileName);
|
|
var directoryInfo = new DirectoryInfo(initializedMimeMessageInformation.Path);
|
|
|
|
var fileExists = File.Exists(fileFolderPath);
|
|
|
|
if (!fileExists)
|
|
await SaveAttachmentInternalAsync(attachmentViewModel, initializedMimeMessageInformation.Path);
|
|
|
|
await LaunchFileInternalAsync(fileFolderPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Failed to open attachment.");
|
|
|
|
_dialogService.InfoBarMessage(Translator.Info_AttachmentOpenFailedTitle, Translator.Info_AttachmentOpenFailedMessage, InfoBarMessageType.Error);
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task SaveAttachmentAsync(MailAttachmentViewModel attachmentViewModel)
|
|
{
|
|
if (attachmentViewModel == null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
attachmentViewModel.IsBusy = true;
|
|
|
|
var pickedPath = await _dialogService.PickWindowsFolderAsync();
|
|
|
|
if (string.IsNullOrEmpty(pickedPath)) return;
|
|
|
|
await SaveAttachmentInternalAsync(attachmentViewModel, pickedPath);
|
|
|
|
_dialogService.InfoBarMessage(Translator.Info_AttachmentSaveSuccessTitle, Translator.Info_AttachmentSaveSuccessMessage, InfoBarMessageType.Success);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Failed to save attachment.");
|
|
|
|
_dialogService.InfoBarMessage(Translator.Info_AttachmentSaveFailedTitle, Translator.Info_AttachmentSaveFailedMessage, InfoBarMessageType.Error);
|
|
}
|
|
finally
|
|
{
|
|
attachmentViewModel.IsBusy = false;
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task SaveAllAttachmentsAsync()
|
|
{
|
|
var pickedPath = await _dialogService.PickWindowsFolderAsync();
|
|
|
|
if (string.IsNullOrEmpty(pickedPath)) return;
|
|
|
|
try
|
|
{
|
|
|
|
foreach (var attachmentViewModel in Attachments)
|
|
{
|
|
await SaveAttachmentInternalAsync(attachmentViewModel, pickedPath);
|
|
}
|
|
|
|
_dialogService.InfoBarMessage(Translator.Info_AttachmentSaveSuccessTitle, Translator.Info_AttachmentSaveSuccessMessage, InfoBarMessageType.Success);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Failed to save attachment.");
|
|
|
|
_dialogService.InfoBarMessage(Translator.Info_AttachmentSaveFailedTitle, Translator.Info_AttachmentSaveFailedMessage, InfoBarMessageType.Error);
|
|
}
|
|
}
|
|
|
|
private async Task SaveAsAsync()
|
|
{
|
|
try
|
|
{
|
|
var pickedFolder = await _dialogService.PickWindowsFolderAsync();
|
|
|
|
if (string.IsNullOrEmpty(pickedFolder)) return;
|
|
|
|
var pdfFilePath = Path.Combine(pickedFolder, $"{initializedMailItemViewModel.FromAddress}.pdf");
|
|
|
|
bool isSaved = await SaveHTMLasPDFFunc(pdfFilePath);
|
|
|
|
if (isSaved)
|
|
{
|
|
_dialogService.InfoBarMessage(Translator.Info_PDFSaveSuccessTitle,
|
|
string.Format(Translator.Info_PDFSaveSuccessMessage, pdfFilePath),
|
|
InfoBarMessageType.Success);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Failed to save as PDF.");
|
|
_dialogService.InfoBarMessage(Translator.Info_PDFSaveFailedTitle, ex.Message, InfoBarMessageType.Error);
|
|
}
|
|
}
|
|
|
|
// Returns created file path.
|
|
private async Task<string> SaveAttachmentInternalAsync(MailAttachmentViewModel attachmentViewModel, string saveFolderPath)
|
|
{
|
|
var fullFilePath = Path.Combine(saveFolderPath, attachmentViewModel.FileName);
|
|
var stream = await _fileService.GetFileStreamAsync(saveFolderPath, attachmentViewModel.FileName);
|
|
|
|
using (stream)
|
|
{
|
|
await attachmentViewModel.MimeContent.DecodeToAsync(stream);
|
|
}
|
|
|
|
return fullFilePath;
|
|
}
|
|
|
|
private async Task LaunchFileInternalAsync(string filePath)
|
|
{
|
|
try
|
|
{
|
|
await NativeAppService.LaunchFileAsync(filePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_dialogService.InfoBarMessage(Translator.Info_FileLaunchFailedTitle, ex.Message, InfoBarMessageType.Error);
|
|
}
|
|
}
|
|
|
|
void ITransferProgress.Report(long bytesTransferred, long totalSize)
|
|
=> _ = ExecuteUIThread(() => { CurrentDownloadPercentage = bytesTransferred * 100 / Math.Max(1, totalSize); });
|
|
|
|
// For upload.
|
|
void ITransferProgress.Report(long bytesTransferred) { }
|
|
|
|
public async void Receive(NewMailItemRenderingRequestedEvent message)
|
|
{
|
|
try
|
|
{
|
|
await RenderAsync(message.MailItemViewModel, renderCancellationTokenSource.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Log.Information("Canceled mail rendering.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_dialogService.InfoBarMessage(Translator.Info_MailRenderingFailedTitle, string.Format(Translator.Info_MailRenderingFailedMessage, ex.Message), InfoBarMessageType.Error);
|
|
|
|
Log.Error(ex, "Failed to render mail.");
|
|
}
|
|
}
|
|
|
|
public void Receive(ThumbnailAdded message)
|
|
{
|
|
UpdateThumbnails(ToItems, message.Email);
|
|
UpdateThumbnails(CcItems, message.Email);
|
|
UpdateThumbnails(BccItems, message.Email);
|
|
}
|
|
|
|
private void UpdateThumbnails(ObservableCollection<AccountContactViewModel> items, string email)
|
|
{
|
|
if (Dispatcher == null || items.Count == 0) return;
|
|
|
|
Dispatcher.ExecuteOnUIThread(() =>
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
if (item.Address.Equals(email, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
item.ThumbnailUpdatedEvent = !item.ThumbnailUpdatedEvent;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task ShowSmimeSigningCertificateInfoAsync()
|
|
{
|
|
if (IsSmimeSigned)
|
|
{
|
|
MimePart signaturePart;
|
|
if (initializedMimeMessageInformation?.MimeMessage?.Body is MultipartSigned signed && signed[1] is MimePart signaturePart1)
|
|
{
|
|
signaturePart = signaturePart1;
|
|
}
|
|
else if (initializedMimeMessageInformation?.MimeMessage?.Body is ApplicationPkcs7Mime pkcs7)
|
|
{
|
|
signaturePart = null;
|
|
}
|
|
else
|
|
{
|
|
//_dialogService.InfoBarMessage(Translator.Info_SmimeSignatureNotFoundTitle, Translator.Info_SmimeSignatureNotFoundMessage, InfoBarMessageType.Error);
|
|
return;
|
|
}
|
|
|
|
string info = $"{Translator.SmimeSignaturesInMessage}:\n";
|
|
foreach (var (signature, valid) in CurrentRenderModel.Signatures)
|
|
{
|
|
info += string.Format(Translator.SmimeSignatureEntry, valid ? "✅" : "❌", signature.SignerCertificate.Name, signature.SignerCertificate.Fingerprint, signature.SignerCertificate.CreationDate, signature.SignerCertificate.ExpirationDate);
|
|
}
|
|
await ShowSmimeCertificateInfoAsync(signaturePart, info, Translator.SmimeSigningCertificateInfoTitle);
|
|
}
|
|
|
|
}
|
|
|
|
private async Task ShowSmimeCertificateInfoAsync(MimePart certificateAttachment, string additionalInfo = "", string title = null)
|
|
{
|
|
{
|
|
if (certificateAttachment == null)
|
|
{
|
|
await _dialogService.ShowConfirmationDialogAsync(
|
|
$"{additionalInfo}\n{Translator.SmimeNoCertificateFileFound}", title ?? Translator.SmimeCertificateInfoTitle, Translator.Buttons_OK);
|
|
return;
|
|
}
|
|
var fileName = certificateAttachment.FileName ?? "smime.p7s";
|
|
var contentType = certificateAttachment.ContentType?.MimeType ?? "application/pkcs7-signature";
|
|
var size = certificateAttachment.Content?.Stream?.Length ?? 0;
|
|
var info = string.Format(Translator.SmimeCertificateFileInfo, fileName, contentType, size);
|
|
|
|
var result = await _dialogService.ShowConfirmationDialogAsync(
|
|
$"{additionalInfo}\n{info}", title ?? Translator.SmimeCertificateInfoTitle,
|
|
Translator.SmimeSaveCertificate);
|
|
if (result)
|
|
{
|
|
var pickedPath = await _dialogService.PickFilePathAsync(fileName);
|
|
if (!string.IsNullOrEmpty(pickedPath))
|
|
{
|
|
var pickedDirectory = Path.GetDirectoryName(pickedPath);
|
|
var pickedFileName = Path.GetFileName(pickedPath);
|
|
await using (var stream = await _fileService.GetFileStreamAsync(pickedDirectory, pickedFileName))
|
|
{
|
|
await certificateAttachment.Content!.DecodeToAsync(stream);
|
|
}
|
|
|
|
_dialogService.InfoBarMessage(Translator.SmimeCertificate, string.Format(Translator.SmimeCertificateSavedTo, pickedPath),
|
|
InfoBarMessageType.Success);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
protected override void RegisterRecipients()
|
|
{
|
|
base.RegisterRecipients();
|
|
|
|
Messenger.Register<NewMailItemRenderingRequestedEvent>(this);
|
|
Messenger.Register<ThumbnailAdded>(this);
|
|
}
|
|
|
|
protected override void UnregisterRecipients()
|
|
{
|
|
base.UnregisterRecipients();
|
|
|
|
Messenger.Unregister<NewMailItemRenderingRequestedEvent>(this);
|
|
Messenger.Unregister<ThumbnailAdded>(this);
|
|
}
|
|
}
|