diff --git a/Wino.Core/Authenticators/OutlookAuthenticator.cs b/Wino.Core/Authenticators/OutlookAuthenticator.cs index 52bd8654..4fa78378 100644 --- a/Wino.Core/Authenticators/OutlookAuthenticator.cs +++ b/Wino.Core/Authenticators/OutlookAuthenticator.cs @@ -28,7 +28,7 @@ namespace Wino.Core.Authenticators public string ClientId { get; } = "b19c2035-d740-49ff-b297-de6ec561b208"; - private readonly string[] MailScope = ["email", "mail.readwrite", "offline_access", "mail.send"]; + private readonly string[] MailScope = ["email", "mail.readwrite", "offline_access", "mail.send", "Mail.Send.Shared", "Mail.ReadWrite.Shared"]; public override MailProviderType ProviderType => MailProviderType.Outlook; diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 2d542849..269bd55f 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -1,5 +1,9 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using Microsoft.Graph.Models; +using MimeKit; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; @@ -61,5 +65,143 @@ namespace Wino.Core.Extensions return mailCopy; } + + public static Message AsOutlookMessage(this MimeMessage mime, string threadId) + { + var fromAddress = GetRecipients(mime.From).ElementAt(0); + var toAddresses = GetRecipients(mime.To).ToList(); + var ccAddresses = GetRecipients(mime.Cc).ToList(); + var bccAddresses = GetRecipients(mime.Bcc).ToList(); + var replyToAddresses = GetRecipients(mime.ReplyTo).ToList(); + + var message = new Message() + { + Subject = mime.Subject, + Importance = GetImportance(mime.Importance), + Body = new ItemBody() { ContentType = BodyType.Html, Content = mime.HtmlBody }, + IsDraft = false, + IsRead = true, // Sent messages are always read. + ToRecipients = toAddresses, + CcRecipients = ccAddresses, + BccRecipients = bccAddresses, + From = fromAddress, + InternetMessageId = GetMessageIdHeader(mime.MessageId), + ConversationId = threadId, + InternetMessageHeaders = GetHeaderList(mime), + ReplyTo = replyToAddresses, + Attachments = [] + }; + + foreach (var part in mime.BodyParts) + { + if (part.IsAttachment) + { + // File attachment. + + using var memory = new MemoryStream(); + ((MimePart)part).Content.DecodeTo(memory); + + var bytes = memory.ToArray(); + + var fileAttachment = new FileAttachment() + { + ContentId = part.ContentId, + Name = part.ContentDisposition?.FileName ?? part.ContentType.Name, + ContentBytes = bytes, + }; + + message.Attachments.Add(fileAttachment); + } + else if (part.ContentDisposition != null && part.ContentDisposition.Disposition == "inline") + { + // Inline attachment. + + using var memory = new MemoryStream(); + ((MimePart)part).Content.DecodeTo(memory); + + var bytes = memory.ToArray(); + var inlineAttachment = new FileAttachment() + { + IsInline = true, + ContentId = part.ContentId, + Name = part.ContentDisposition?.FileName ?? part.ContentType.Name, + ContentBytes = bytes + }; + + message.Attachments.Add(inlineAttachment); + } + } + + return message; + } + + #region Mime to Outlook Message Helpers + + private static IEnumerable GetRecipients(this InternetAddressList internetAddresses) + { + foreach (var address in internetAddresses) + { + if (address is MailboxAddress mailboxAddress) + yield return new Recipient() { EmailAddress = new EmailAddress() { Address = mailboxAddress.Address, Name = mailboxAddress.Name } }; + else if (address is GroupAddress groupAddress) + { + // TODO: Group addresses are not directly supported. + // It'll be individually added. + + foreach (var mailbox in groupAddress.Members) + if (mailbox is MailboxAddress groupMemberMailAddress) + yield return new Recipient() { EmailAddress = new EmailAddress() { Address = groupMemberMailAddress.Address, Name = groupMemberMailAddress.Name } }; + } + } + } + + private static Importance? GetImportance(MessageImportance importance) + { + return importance switch + { + MessageImportance.Low => Importance.Low, + MessageImportance.Normal => Importance.Normal, + MessageImportance.High => Importance.High, + _ => null + }; + } + + private static List GetHeaderList(this MimeMessage mime) + { + // Graph API only allows max of 5 headers. + // Here we'll try to ignore some headers that are not neccessary. + // Outlook API will generate them automatically. + + string[] headersToIgnore = ["Date", "To", "MIME-Version", "From", "Subject", "Message-Id"]; + + var headers = new List(); + + int includedHeaderCount = 0; + + foreach (var header in mime.Headers) + { + if (!headersToIgnore.Contains(header.Field)) + { + headers.Add(new InternetMessageHeader() { Name = header.Field, Value = header.Value }); + includedHeaderCount++; + } + + if (includedHeaderCount >= 5) break; + } + + return headers; + } + + private static string GetMessageIdHeader(string messageId) + { + // Message-Id header must always start with "X-" or "x-". + if (string.IsNullOrEmpty(messageId)) return string.Empty; + + if (!messageId.StartsWith("x-") || !messageId.StartsWith("X-")) + return $"X-{messageId}"; + + return messageId; + } + #endregion } } diff --git a/Wino.Core/Services/MimeFileService.cs b/Wino.Core/Services/MimeFileService.cs index 02768349..42a93f65 100644 --- a/Wino.Core/Services/MimeFileService.cs +++ b/Wino.Core/Services/MimeFileService.cs @@ -109,12 +109,9 @@ namespace Wino.Core.Services var resourcePath = await GetMimeResourcePathAsync(accountId, fileId).ConfigureAwait(false); var completeFilePath = GetEMLPath(resourcePath); - var fileStream = File.Create(completeFilePath); + using var fileStream = File.Open(completeFilePath, FileMode.OpenOrCreate); - using (fileStream) - { - await mimeMessage.WriteToAsync(fileStream).ConfigureAwait(false); - } + await mimeMessage.WriteToAsync(fileStream).ConfigureAwait(false); return true; } diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 964d5c5b..d2591118 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -10,6 +10,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Graph; +using Microsoft.Graph.Me.SendMail; using Microsoft.Graph.Models; using Microsoft.Kiota.Abstractions; using Microsoft.Kiota.Abstractions.Authentication; @@ -17,8 +18,8 @@ using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; using MimeKit; using MoreLinq.Extensions; +using Newtonsoft.Json.Linq; using Serilog; -using Wino.Core.Domain; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; @@ -633,41 +634,26 @@ namespace Wino.Core.Synchronizers var mailCopyId = sendDraftPreparationRequest.MailItem.Id; var mimeMessage = sendDraftPreparationRequest.Mime; - var batchDeleteRequest = new BatchDeleteRequest(new List() - { + var batchDeleteRequest = new BatchDeleteRequest( + [ new DeleteRequest(sendDraftPreparationRequest.MailItem) - }); + ]); var deleteBundle = Delete(batchDeleteRequest).ElementAt(0); - mimeMessage.Prepare(EncodingConstraint.None); + // Convert mime message to Outlook message. + // Outlook synchronizer does not send MIME messages directly anymore. + // Alias support is lacking with direct MIMEs. + // Therefore we convert the MIME message to Outlook message and use proper APIs. - var plainTextBytes = Encoding.UTF8.GetBytes(mimeMessage.ToString()); - var base64Encoded = Convert.ToBase64String(plainTextBytes); + var outlookMessage = mimeMessage.AsOutlookMessage(sendDraftPreparationRequest.MailItem.ThreadId); - var outlookMessage = new Message() - { - ConversationId = sendDraftPreparationRequest.MailItem.ThreadId - }; - - // Apply importance here as well just in case. - if (mimeMessage.Importance != MessageImportance.Normal) - outlookMessage.Importance = mimeMessage.Importance == MessageImportance.High ? Importance.High : Importance.Low; - - var body = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody() + var sendMailPostRequest = _graphClient.Me.SendMail.ToPostRequestInformation(new SendMailPostRequestBody() { Message = outlookMessage - }; + }); - var sendRequest = _graphClient.Me.SendMail.ToPostRequestInformation(body); - - sendRequest.Headers.Clear(); - sendRequest.Headers.Add("Content-Type", "text/plain"); - - var stream = new MemoryStream(Encoding.UTF8.GetBytes(base64Encoded)); - sendRequest.SetStreamContent(stream, "text/plain"); - - var sendMailRequest = new HttpRequestBundle(sendRequest, request); + var sendMailRequest = new HttpRequestBundle(sendMailPostRequest, request); return [deleteBundle, sendMailRequest]; } @@ -675,8 +661,6 @@ namespace Wino.Core.Synchronizers public override IEnumerable> Archive(BatchArchiveRequest request) => Move(new BatchMoveRequest(request.Items, request.FromFolder, request.ToFolder)); - - public override async Task DownloadMissingMimeMessageAsync(IMailItem mailItem, MailKit.ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) @@ -774,7 +758,10 @@ namespace Wino.Core.Synchronizers if (!httpResponseMessage.IsSuccessStatusCode) { - throw new SynchronizerException(string.Format(Translator.Exception_SynchronizerFailureHTTP, httpResponseMessage.StatusCode)); + var content = await httpResponseMessage.Content.ReadAsStringAsync(); + var errorJson = JObject.Parse(content); + + throw new SynchronizerException($"({httpResponseMessage.StatusCode}) {errorJson["error"]["code"]} - {errorJson["error"]["message"]}"); } else if (bundle is HttpRequestBundle messageBundle) { diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs index 983ed09a..f91ca00a 100644 --- a/Wino.Mail.ViewModels/ComposePageViewModel.cs +++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs @@ -135,19 +135,19 @@ namespace Wino.Mail.ViewModels IWinoServerConnectionManager winoServerConnectionManager) : base(dialogService) { NativeAppService = nativeAppService; - _folderService = folderService; ContactService = contactService; FontService = fontService; + PreferencesService = preferencesService; + _folderService = folderService; _mailService = mailService; _launchProtocolService = launchProtocolService; _mimeFileService = mimeFileService; _accountService = accountService; _worker = worker; + _winoServerConnectionManager = winoServerConnectionManager; SelectedToolbarSection = ToolbarSections[0]; - PreferencesService = preferencesService; - _winoServerConnectionManager = winoServerConnectionManager; } [RelayCommand] @@ -202,6 +202,16 @@ namespace Wino.Mail.ViewModels await _worker.ExecuteAsync(draftSendPreparationRequest); } + public async Task IncludeAttachmentAsync(MailAttachmentViewModel viewModel) + { + //if (bodyBuilder == null) return; + + //bodyBuilder.Attachments.Add(viewModel.FileName, new MemoryStream(viewModel.Content)); + + //LoadAttachments(); + IncludedAttachments.Add(viewModel); + } + private async Task UpdateMimeChangesAsync() { if (isUpdatingMimeBlocked || CurrentMimeMessage == null || ComposingAccount == null || CurrentMailDraftItem == null) return; @@ -230,6 +240,7 @@ namespace Wino.Mail.ViewModels CurrentMailDraftItem.Subject = CurrentMimeMessage.Subject; CurrentMailDraftItem.PreviewText = CurrentMimeMessage.TextBody; CurrentMailDraftItem.FromAddress = SelectedAlias.AliasAddress; + CurrentMailDraftItem.HasAttachments = CurrentMimeMessage.Attachments.Any(); // Update database. await _mailService.UpdateMailAsync(CurrentMailDraftItem.MailCopy); @@ -260,6 +271,31 @@ namespace Wino.Mail.ViewModels } } + private void ClearCurrentMimeAttachments() + { + var attachments = new List(); + var multiparts = new List(); + var iter = new MimeIterator(CurrentMimeMessage); + + // collect our list of attachments and their parent multiparts + while (iter.MoveNext()) + { + var multipart = iter.Parent as Multipart; + var part = iter.Current as MimePart; + + if (multipart != null && part != null && part.IsAttachment) + { + // keep track of each attachment's parent multipart + multiparts.Add(multipart); + attachments.Add(part); + } + } + + // now remove each attachment from its parent multipart... + for (int i = 0; i < attachments.Count; i++) + multiparts[i].Remove(attachments[i]); + } + private async Task SaveBodyAsync() { if (GetHTMLBodyFunction != null) @@ -267,8 +303,7 @@ namespace Wino.Mail.ViewModels bodyBuilder.SetHtmlBody(await GetHTMLBodyFunction()); } - if (bodyBuilder.HtmlBody != null && bodyBuilder.TextBody != null) - CurrentMimeMessage.Body = bodyBuilder.ToMessageBody(); + CurrentMimeMessage.Body = bodyBuilder.ToMessageBody(); } [RelayCommand(CanExecute = nameof(canSendMail))] @@ -306,9 +341,11 @@ namespace Wino.Mail.ViewModels base.OnNavigatedFrom(mode, parameters); await UpdateMimeChangesAsync().ConfigureAwait(false); + + Messenger.Send(new KillChromiumRequested()); } - public override void OnNavigatedTo(NavigationMode mode, object parameters) + public override async void OnNavigatedTo(NavigationMode mode, object parameters) { base.OnNavigatedTo(mode, parameters); @@ -316,29 +353,7 @@ namespace Wino.Mail.ViewModels { CurrentMailDraftItem = mailItem; - _ = TryPrepareComposeAsync(true); - } - - ToItems.CollectionChanged -= ContactListCollectionChanged; - ToItems.CollectionChanged += ContactListCollectionChanged; - } - - private void ContactListCollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - { - if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add) - { - // Prevent duplicates. - if (!(sender is ObservableCollection list)) - return; - - foreach (var item in e.NewItems) - { - if (item is AddressInformation addedInfo && list.Count(a => a == addedInfo) > 1) - { - var addedIndex = list.IndexOf(addedInfo); - list.RemoveAt(addedIndex); - } - } + await TryPrepareComposeAsync(true); } } @@ -436,6 +451,8 @@ namespace Wino.Mail.ViewModels { // Extract information + CurrentMimeMessage = replyingMime; + ToItems.Clear(); CCItems.Clear(); BCCItems.Clear(); @@ -444,22 +461,22 @@ namespace Wino.Mail.ViewModels LoadAddressInfo(replyingMime.Cc, CCItems); LoadAddressInfo(replyingMime.Bcc, BCCItems); - LoadAttachments(replyingMime.Attachments); + LoadAttachments(); if (replyingMime.Cc.Any() || replyingMime.Bcc.Any()) IsCCBCCVisible = true; Subject = replyingMime.Subject; - CurrentMimeMessage = replyingMime; - Messenger.Send(new CreateNewComposeMailRequested(renderModel)); }); } - private void LoadAttachments(IEnumerable mimeEntities) + private void LoadAttachments() { - foreach (var attachment in mimeEntities) + if (CurrentMimeMessage == null) return; + + foreach (var attachment in CurrentMimeMessage.Attachments) { if (attachment.IsAttachment && attachment is MimePart attachmentPart) { @@ -481,18 +498,15 @@ namespace Wino.Mail.ViewModels private void SaveFromAddress() { - if (SelectedAlias == null || CurrentMimeMessage == null) return; - + if (SelectedAlias == null) return; CurrentMimeMessage.From.Clear(); CurrentMimeMessage.From.Add(new MailboxAddress(ComposingAccount.SenderName, SelectedAlias.AliasAddress)); - - } private void SaveReplyToAddress() { - if (SelectedAlias == null || CurrentMimeMessage == null) return; + if (SelectedAlias == null) return; if (!string.IsNullOrEmpty(SelectedAlias.ReplyToAddress)) { diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs index 3caf3398..d96e486e 100644 --- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs @@ -18,7 +18,6 @@ namespace Wino.Mail.ViewModels.Data public string MessageId => ((IMailItem)MailCopy).MessageId; public string FromName => ((IMailItem)MailCopy).FromName ?? FromAddress; public DateTime CreationDate => ((IMailItem)MailCopy).CreationDate; - public bool HasAttachments => ((IMailItem)MailCopy).HasAttachments; public string References => ((IMailItem)MailCopy).References; public string InReplyTo => ((IMailItem)MailCopy).InReplyTo; @@ -82,6 +81,12 @@ namespace Wino.Mail.ViewModels.Data set => SetProperty(MailCopy.FromAddress, value, MailCopy, (u, n) => u.FromAddress = n); } + public bool HasAttachments + { + get => MailCopy.HasAttachments; + set => SetProperty(MailCopy.HasAttachments, value, MailCopy, (u, n) => u.HasAttachments = n); + } + public MailItemFolder AssignedFolder => ((IMailItem)MailCopy).AssignedFolder; public MailAccount AssignedAccount => ((IMailItem)MailCopy).AssignedAccount; @@ -99,6 +104,8 @@ namespace Wino.Mail.ViewModels.Data OnPropertyChanged(nameof(DraftId)); OnPropertyChanged(nameof(Subject)); OnPropertyChanged(nameof(PreviewText)); + OnPropertyChanged(nameof(FromAddress)); + OnPropertyChanged(nameof(HasAttachments)); } public IEnumerable GetContainingIds() => new[] { UniqueId }; diff --git a/Wino.Mail/AppShell.xaml.cs b/Wino.Mail/AppShell.xaml.cs index e6c431ce..966d1c31 100644 --- a/Wino.Mail/AppShell.xaml.cs +++ b/Wino.Mail/AppShell.xaml.cs @@ -208,7 +208,6 @@ namespace Wino.Views WeakReferenceMessenger.Default.Send(new ClearMailSelectionsRequested()); WeakReferenceMessenger.Default.Send(new DisposeRenderingFrameRequested()); WeakReferenceMessenger.Default.Send(new ShellStateUpdated()); - } private async void MenuItemContextRequested(UIElement sender, ContextRequestedEventArgs args) diff --git a/Wino.Mail/Services/WinoNavigationService.cs b/Wino.Mail/Services/WinoNavigationService.cs index 782cf582..b2dd7f47 100644 --- a/Wino.Mail/Services/WinoNavigationService.cs +++ b/Wino.Mail/Services/WinoNavigationService.cs @@ -11,6 +11,7 @@ using Wino.Core.Domain.Models.Navigation; using Wino.Helpers; using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Messages; +using Wino.Messaging.Client.Mails; using Wino.Views; using Wino.Views.Account; using Wino.Views.Settings; @@ -116,6 +117,7 @@ namespace Wino.Services { // No need for new navigation, just refresh the folder. WeakReferenceMessenger.Default.Send(new ActiveMailFolderChangedEvent(folderNavigationArgs.BaseFolderMenuItem, folderNavigationArgs.FolderInitLoadAwaitTask)); + WeakReferenceMessenger.Default.Send(new DisposeRenderingFrameRequested()); return true; } @@ -138,6 +140,13 @@ namespace Wino.Services { WeakReferenceMessenger.Default.Send(new NewMailItemRenderingRequestedEvent(mailItemViewModel)); } + else if (listingFrame.Content != null + && listingFrame.Content.GetType() == GetPageType(WinoPage.IdlePage) + && pageType == typeof(IdlePage)) + { + // Idle -> Idle navigation. Ignore. + return true; + } else { listingFrame.Navigate(pageType, parameter, transitionInfo); diff --git a/Wino.Mail/Views/ComposePage.xaml b/Wino.Mail/Views/ComposePage.xaml index b7a10162..60aac3a0 100644 --- a/Wino.Mail/Views/ComposePage.xaml +++ b/Wino.Mail/Views/ComposePage.xaml @@ -1,22 +1,23 @@ - + @@ -29,10 +30,11 @@ - + @@ -41,9 +43,10 @@ - + @@ -53,10 +56,11 @@ - + @@ -64,54 +68,61 @@ - + - + - + - + - + - + - + Transparent @@ -124,32 +135,36 @@ - + - + - + - + @@ -168,9 +183,10 @@ - + @@ -183,34 +199,39 @@ - - + + - + - + - + @@ -218,17 +239,19 @@ - + - + @@ -236,9 +259,10 @@ - + @@ -246,9 +270,10 @@ - + @@ -257,11 +282,12 @@ - + @@ -302,10 +328,11 @@ - + @@ -313,29 +340,33 @@ - - + + - + - + @@ -383,10 +414,11 @@ - + @@ -402,128 +434,142 @@ - + - + - + - + - + - - + - + - + - + - + - + - + @@ -533,65 +579,72 @@ - + - - + + - + - + - - + + diff --git a/Wino.Mail/Views/ComposePage.xaml.cs b/Wino.Mail/Views/ComposePage.xaml.cs index b1b01c25..663cebf9 100644 --- a/Wino.Mail/Views/ComposePage.xaml.cs +++ b/Wino.Mail/Views/ComposePage.xaml.cs @@ -39,7 +39,8 @@ namespace Wino.Views public sealed partial class ComposePage : ComposePageAbstract, IRecipient, IRecipient, - IRecipient + IRecipient, + IRecipient { public bool IsComposerDarkMode { @@ -237,12 +238,9 @@ namespace Wino.Views // Convert files to MailAttachmentViewModel. foreach (var file in files) { - if (!ViewModel.IncludedAttachments.Any(a => a.FileName == file.Path)) - { - var attachmentViewModel = await file.ToAttachmentViewModelAsync(); + var attachmentViewModel = await file.ToAttachmentViewModelAsync(); - ViewModel.IncludedAttachments.Add(attachmentViewModel); - } + await ViewModel.IncludeAttachmentAsync(attachmentViewModel); } } @@ -417,13 +415,6 @@ namespace Wino.Views return await ExecuteScriptFunctionAsync("initializeJodit", fonts, composerFont, composerFontSize, readerFont, readerFontSize); } - protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) - { - base.OnNavigatingFrom(e); - - DisposeDisposables(); - DisposeWebView2(); - } private void DisposeWebView2() { @@ -700,5 +691,11 @@ namespace Wino.Views ToBox.Focus(FocusState.Programmatic); } } + + public void Receive(KillChromiumRequested message) + { + DisposeDisposables(); + DisposeWebView2(); + } } } diff --git a/Wino.Messages/Client/Mails/KillChromiumRequested.cs b/Wino.Messages/Client/Mails/KillChromiumRequested.cs new file mode 100644 index 00000000..659adee6 --- /dev/null +++ b/Wino.Messages/Client/Mails/KillChromiumRequested.cs @@ -0,0 +1,7 @@ +namespace Wino.Messaging.Client.Mails +{ + /// + /// Terminates all chromum instances. + /// + public record KillChromiumRequested; +}