From d28de50ec6398cc4591e024c033790a3e835a7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 7 Feb 2026 13:10:57 +0100 Subject: [PATCH] Fixing outlook attachments, re-using compose page and some additional fixes on the mime headers for outlook. --- .../Extensions/OutlookIntegratorExtensions.cs | 106 ++++++++++++------ Wino.Core/Misc/OutlookFileAttachment.cs | 24 ---- .../Synchronizers/OutlookSynchronizer.cs | 95 ++++------------ Wino.Mail.ViewModels/ComposePageViewModel.cs | 35 +++++- Wino.Mail.ViewModels/MailListPageViewModel.cs | 61 ++++++++++ .../NewComposeDraftItemRequestedEvent.cs | 10 ++ Wino.Mail.WinUI/Services/NavigationService.cs | 9 ++ .../Views/Mail/ComposePage.xaml.cs | 12 +- .../Views/Mail/MailListPage.xaml.cs | 5 +- Wino.Services/MailService.cs | 22 ++-- 10 files changed, 234 insertions(+), 145 deletions(-) delete mode 100644 Wino.Core/Misc/OutlookFileAttachment.cs create mode 100644 Wino.Mail.ViewModels/Messages/NewComposeDraftItemRequestedEvent.cs diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 894b1bce..42223fcb 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text; using Microsoft.Graph.Models; @@ -138,37 +139,38 @@ public static class OutlookIntegratorExtensions var bccAddresses = GetRecipients(mime.Bcc).ToList(); var replyToAddresses = GetRecipients(mime.ReplyTo).ToList(); + // Prefer HTML body, fall back to plain text. + var (bodyContent, bodyType) = mime.HtmlBody != null + ? (mime.HtmlBody, BodyType.Html) + : (mime.TextBody ?? string.Empty, BodyType.Text); + var message = new Message() { Subject = mime.Subject, Importance = GetImportance(mime.Importance), - Body = new ItemBody() { ContentType = BodyType.Html, Content = mime.HtmlBody }, + Body = new ItemBody() { ContentType = bodyType, Content = bodyContent }, IsDraft = false, - IsRead = true, // Sent messages are always read. + IsRead = true, ToRecipients = toAddresses, CcRecipients = ccAddresses, BccRecipients = bccAddresses, From = fromAddress, InternetMessageId = mime.MessageId, ReplyTo = replyToAddresses, - Attachments = [] }; - // Set ConversationId if provided to maintain threading if (!string.IsNullOrEmpty(conversationId)) { message.ConversationId = conversationId; } // Headers are only included when creating the draft. - // When sending, they are not included. Graph will throw an error. - + // Graph API throws an error if headers are included in send/patch operations. if (includeInternetHeaders) { message.InternetMessageHeaders = GetHeaderList(mime); } - return message; } @@ -367,6 +369,40 @@ public static class OutlookIntegratorExtensions #region Mime to Outlook Message Helpers + /// + /// Extracts all attachments (inline and regular) from a MimeMessage + /// and returns them as Graph SDK FileAttachment objects. + /// + public static List ExtractAttachments(this MimeMessage mime) + { + var attachments = new List(); + + foreach (var part in mime.BodyParts) + { + bool isInline = part.ContentDisposition?.Disposition == "inline"; + + if (!part.IsAttachment && !isInline) + continue; + + if (part is not MimePart mimePart || mimePart.Content == null) + continue; + + using var memory = new MemoryStream(); + mimePart.Content.DecodeTo(memory); + + attachments.Add(new FileAttachment() + { + Name = part.ContentDisposition?.FileName ?? part.ContentType.Name, + ContentBytes = memory.ToArray(), + ContentType = part.ContentType.MimeType, + ContentId = part.ContentId, + IsInline = isInline + }); + } + + return attachments; + } + private static IEnumerable GetRecipients(this InternetAddressList internetAddresses) { foreach (var address in internetAddresses) @@ -399,46 +435,46 @@ public static class OutlookIntegratorExtensions 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. - - // Some headers also require to start with X- or x-. + // Prioritize threading headers to keep reply grouping intact. + const int headerLimit = 5; string[] headersToIgnore = ["Date", "To", "Cc", "Bcc", "MIME-Version", "From", "Subject", "Message-Id"]; - string[] headersToModify = ["In-Reply-To", "Reply-To", "References", "Thread-Topic"]; var headers = new List(); - // PRIORITY: Always include WinoLocalDraftHeader first if it exists - // This is critical for draft mapping functionality - var winoDraftHeader = mime.Headers.FirstOrDefault(h => h.Field == Domain.Constants.WinoLocalDraftHeader); - int includedHeaderCount = 0; - - if (winoDraftHeader != null) + void AddHeader(string name, string value) { - var headerValue = winoDraftHeader.Value.Length >= 995 ? winoDraftHeader.Value.Substring(0, 995) : winoDraftHeader.Value; - headers.Add(new InternetMessageHeader() { Name = winoDraftHeader.Field, Value = headerValue }); - includedHeaderCount++; + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) return; + if (headers.Count >= headerLimit) return; + if (headers.Any(h => string.Equals(h.Name, name, StringComparison.OrdinalIgnoreCase))) return; + + // No header value should exceed 995 characters. + var headerValue = value.Length >= 995 ? value.Substring(0, 995) : value; + headers.Add(new InternetMessageHeader() { Name = name, Value = headerValue }); } - // Include other headers up to the limit (excluding the already added WinoLocalDraftHeader) + // PRIORITY: Always include WinoLocalDraftHeader first if it exists. + var winoDraftHeader = mime.Headers.FirstOrDefault(h => h.Field == Domain.Constants.WinoLocalDraftHeader); + if (winoDraftHeader != null) + AddHeader(winoDraftHeader.Field, winoDraftHeader.Value); + + // Threading headers must be preserved with their real RFC names. + AddHeader("In-Reply-To", mime.Headers[HeaderId.InReplyTo]); + AddHeader("References", mime.Headers[HeaderId.References]); + + // Fill remaining slots with custom headers only (avoid Graph restrictions). foreach (var header in mime.Headers) { - if (header.Field == Domain.Constants.WinoLocalDraftHeader) - continue; // Already processed above + if (headers.Count >= headerLimit) break; + if (header.Field == Domain.Constants.WinoLocalDraftHeader) continue; + if (headersToIgnore.Contains(header.Field)) continue; + if (string.Equals(header.Field, "In-Reply-To", StringComparison.OrdinalIgnoreCase)) continue; + if (string.Equals(header.Field, "References", StringComparison.OrdinalIgnoreCase)) continue; - if (!headersToIgnore.Contains(header.Field)) - { - var headerName = headersToModify.Contains(header.Field) ? $"X-{header.Field}" : header.Field; + // Only include custom headers beyond the core threading ones. + if (!header.Field.StartsWith("X-", StringComparison.OrdinalIgnoreCase)) continue; - // No header value should exceed 995 characters. - var headerValue = header.Value.Length >= 995 ? header.Value.Substring(0, 995) : header.Value; - - headers.Add(new InternetMessageHeader() { Name = headerName, Value = headerValue }); - includedHeaderCount++; - } - - if (includedHeaderCount >= 5) break; + AddHeader(header.Field, header.Value); } return headers; diff --git a/Wino.Core/Misc/OutlookFileAttachment.cs b/Wino.Core/Misc/OutlookFileAttachment.cs deleted file mode 100644 index 0839657c..00000000 --- a/Wino.Core/Misc/OutlookFileAttachment.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Wino.Core.Misc; - -public class OutlookFileAttachment -{ - [JsonPropertyName("@odata.type")] - public string OdataType { get; } = "#microsoft.graph.fileAttachment"; - - [JsonPropertyName("name")] - public string FileName { get; set; } - - [JsonPropertyName("contentBytes")] - public string Base64EncodedContentBytes { get; set; } - - [JsonPropertyName("contentType")] - public string ContentType { get; set; } - - [JsonPropertyName("contentId")] - public string ContentId { get; set; } - - [JsonPropertyName("isInline")] - public bool IsInline { get; set; } -} diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 1fac6c5a..c1b73098 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -36,7 +36,6 @@ using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Extensions; using Wino.Core.Http; using Wino.Core.Integration.Processors; -using Wino.Core.Misc; using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Calendar; using Wino.Core.Requests.Folder; @@ -45,7 +44,6 @@ using Wino.Core.Requests.Mail; namespace Wino.Core.Synchronizers.Mail; [JsonSerializable(typeof(Microsoft.Graph.Me.Messages.Item.Move.MovePostRequestBody))] -[JsonSerializable(typeof(OutlookFileAttachment))] public partial class OutlookSynchronizerJsonContext : JsonSerializerContext; /// @@ -1382,96 +1380,43 @@ public class OutlookSynchronizer : WinoSynchronizer> SendDraft(SendDraftRequest request) { var sendDraftPreparationRequest = request.Request; - - // 1. Delete draft - // 2. Create new Message with new MIME. - // 3. Make sure that conversation id is tagged correctly for replies. - var mailCopyId = sendDraftPreparationRequest.MailItem.Id; var mimeMessage = sendDraftPreparationRequest.Mime; - // 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. - - // Pass the ConversationId (ThreadId) to maintain threading for replies/forwards + // Graph API ignores the From header in direct MIME uploads, so we must convert + // to a JSON Message object to properly support sending from aliases. var conversationId = sendDraftPreparationRequest.MailItem.ThreadId; var outlookMessage = mimeMessage.AsOutlookMessage(false, conversationId); - // Create attachment requests. - // TODO: We need to support large file attachments with sessioned upload at some point. - - var attachmentRequestList = CreateAttachmentUploadBundles(mimeMessage, mailCopyId, request).ToList(); - - // Update draft. + // Build the request sequence: upload attachments -> patch draft -> send. + // These execute serially via batch DependsOn (see ConfigureSerialExecution). + var attachmentBundles = CreateAttachmentUploadBundles(mimeMessage, mailCopyId); var patchDraftRequest = _graphClient.Me.Messages[mailCopyId].ToPatchRequestInformation(outlookMessage); - var patchDraftRequestBundle = new HttpRequestBundle(patchDraftRequest, request); + var patchDraftBundle = new HttpRequestBundle(patchDraftRequest, request); - // Send draft. + var sendRequest = PreparePostRequestInformation(_graphClient.Me.Messages[mailCopyId].Send.ToPostRequestInformation()); + var sendBundle = new HttpRequestBundle(sendRequest, request); - var sendDraftRequest = PreparePostRequestInformation(_graphClient.Me.Messages[mailCopyId].Send.ToPostRequestInformation()); - var sendDraftRequestBundle = new HttpRequestBundle(sendDraftRequest, request); - - return [.. attachmentRequestList, patchDraftRequestBundle, sendDraftRequestBundle]; + return [.. attachmentBundles, patchDraftBundle, sendBundle]; } - private List> CreateAttachmentUploadBundles(MimeMessage mime, string mailCopyId, IRequestBase sourceRequest) + /// + /// Extracts attachments from the MIME message and creates individual + /// Graph API upload requests using the SDK's FileAttachment type. + /// + private List> CreateAttachmentUploadBundles(MimeMessage mime, string mailCopyId) { - var allAttachments = new List(); + var attachments = mime.ExtractAttachments(); + var bundles = new List>(attachments.Count); - foreach (var part in mime.BodyParts) + foreach (var attachment in attachments) { - var isAttachmentOrInline = part.IsAttachment ? true : part.ContentDisposition?.Disposition == "inline"; - - if (!isAttachmentOrInline) continue; - - using var memory = new MemoryStream(); - ((MimePart)part).Content.DecodeTo(memory); - - var base64String = Convert.ToBase64String(memory.ToArray()); - - var attachment = new OutlookFileAttachment() - { - Base64EncodedContentBytes = base64String, - FileName = part.ContentDisposition?.FileName ?? part.ContentType.Name, - ContentId = part.ContentId, - ContentType = part.ContentType.MimeType, - IsInline = part.ContentDisposition?.Disposition == "inline" - }; - - allAttachments.Add(attachment); + var uploadRequest = _graphClient.Me.Messages[mailCopyId].Attachments.ToPostRequestInformation(attachment); + bundles.Add(new HttpRequestBundle(uploadRequest, null)); } - static RequestInformation PrepareUploadAttachmentRequest(RequestInformation requestInformation, OutlookFileAttachment outlookFileAttachment) - { - requestInformation.Headers.Clear(); - - string contentJson = JsonSerializer.Serialize(outlookFileAttachment, OutlookSynchronizerJsonContext.Default.OutlookFileAttachment); - - requestInformation.Content = new MemoryStream(Encoding.UTF8.GetBytes(contentJson)); - requestInformation.HttpMethod = Method.POST; - requestInformation.Headers.Add("Content-Type", "application/json"); - - return requestInformation; - } - - var retList = new List>(); - - // Prepare attachment upload requests. - - foreach (var attachment in allAttachments) - { - var emptyPostRequest = _graphClient.Me.Messages[mailCopyId].Attachments.ToPostRequestInformation(new Attachment()); - var modifiedAttachmentUploadRequest = PrepareUploadAttachmentRequest(emptyPostRequest, attachment); - - var bundle = new HttpRequestBundle(modifiedAttachmentUploadRequest, null); - - retList.Add(bundle); - } - - return retList; + return bundles; } public override List> Archive(BatchArchiveRequest request) diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs index 17d665f2..4e9eadfb 100644 --- a/Wino.Mail.ViewModels/ComposePageViewModel.cs +++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs @@ -21,11 +21,13 @@ using Wino.Core.Domain.Models.Navigation; using Wino.Core.Extensions; using Wino.Core.Services; using Wino.Mail.ViewModels.Data; +using Wino.Mail.ViewModels.Messages; using Wino.Messaging.Client.Mails; namespace Wino.Mail.ViewModels; -public partial class ComposePageViewModel : MailBaseViewModel +public partial class ComposePageViewModel : MailBaseViewModel, + IRecipient { public Func> GetHTMLBodyFunction; @@ -432,6 +434,35 @@ public partial class ComposePageViewModel : MailBaseViewModel } } + public async void Receive(NewComposeDraftItemRequestedEvent message) + { + // Save current draft before switching. + await UpdateMimeChangesAsync(); + + // Reset state for the new draft. + isUpdatingMimeBlocked = false; + ComposingAccount = null; + IncludedAttachments.Clear(); + + // Set the new draft item and prepare it. + CurrentMailDraftItem = message.MailItemViewModel; + await TryPrepareComposeAsync(true); + } + + protected override void RegisterRecipients() + { + base.RegisterRecipients(); + + Messenger.Register(this); + } + + protected override void UnregisterRecipients() + { + base.UnregisterRecipients(); + + Messenger.Unregister(this); + } + private async Task InitializeComposerAccountAsync() { if (CurrentMailDraftItem == null) return false; @@ -550,6 +581,8 @@ public partial class ComposePageViewModel : MailBaseViewModel { if (CurrentMimeMessage == null) return; + IncludedAttachments.Clear(); + foreach (var attachment in CurrentMimeMessage.Attachments) { if (attachment.IsAttachment && attachment is MimePart attachmentPart) diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index d49d3a56..38597738 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -635,6 +635,56 @@ public partial class MailListPageViewModel : MailBaseViewModel, if (isFromDraftOrSentFolder) { + // Fix for draft duplication: When a draft is created for reply/forward, it's first added as local draft. + // Then the server sync fetches it back. We should skip adding remote drafts if a local draft already exists + // with the same ThreadId. The mapping system (DraftMapped) will handle updating the existing local draft. + if (addedMail.IsDraft && !addedMail.IsLocalDraft && !string.IsNullOrEmpty(addedMail.ThreadId)) + { + // Check if collection already has a local draft with the same ThreadId in the same folder + bool hasLocalDraftInSameThread = false; + + foreach (var group in MailCollection.MailItems) + { + foreach (var item in group) + { + if (item is MailItemViewModel mailItem) + { + if (mailItem.IsDraft && + mailItem.MailCopy.IsLocalDraft && + mailItem.MailCopy.ThreadId == addedMail.ThreadId && + mailItem.MailCopy.FolderId == addedMail.FolderId) + { + hasLocalDraftInSameThread = true; + break; + } + } + else if (item is ThreadMailItemViewModel threadItem) + { + foreach (var threadEmail in threadItem.ThreadEmails) + { + if (threadEmail.IsDraft && + threadEmail.MailCopy.IsLocalDraft && + threadEmail.MailCopy.ThreadId == addedMail.ThreadId && + threadEmail.MailCopy.FolderId == addedMail.FolderId) + { + hasLocalDraftInSameThread = true; + break; + } + } + if (hasLocalDraftInSameThread) break; + } + } + if (hasLocalDraftInSameThread) break; + } + + if (hasLocalDraftInSameThread) + { + // Local draft exists in the same thread - skip adding remote duplicate + // The mapping system will update the local draft with remote IDs when DraftMapped message is received + return; + } + } + // Only add if the ThreadId exists in the collection (can be threaded with existing items) if (!ThreadIdExistsInCollection(addedMail)) return; } @@ -757,6 +807,17 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } + protected override void OnDraftMapped(string localDraftCopyId, string remoteDraftCopyId) + { + base.OnDraftMapped(localDraftCopyId, remoteDraftCopyId); + + // When a draft is mapped from local to remote, the database has been updated + // but the UI collection still references the MailCopy object with old IDs. + // The MailCollection.AddAsync method checks UniqueId (which doesn't change during mapping) + // so if mapping worked correctly, no duplicate should appear. + // This method is here for future enhancements if additional UI updates are needed. + } + private async Task> PrepareMailViewModelsAsync(IEnumerable mailItems, CancellationToken cancellationToken = default) { // Run ViewModel creation on background thread to avoid blocking UI diff --git a/Wino.Mail.ViewModels/Messages/NewComposeDraftItemRequestedEvent.cs b/Wino.Mail.ViewModels/Messages/NewComposeDraftItemRequestedEvent.cs new file mode 100644 index 00000000..1b94c725 --- /dev/null +++ b/Wino.Mail.ViewModels/Messages/NewComposeDraftItemRequestedEvent.cs @@ -0,0 +1,10 @@ +using Wino.Mail.ViewModels.Data; + +namespace Wino.Mail.ViewModels.Messages; + +/// +/// When the compose page is already active, but a different draft item is selected. +/// To not trigger navigation again and re-use existing WebView2 editor. +/// +/// The new draft mail item to compose. +public record NewComposeDraftItemRequestedEvent(MailItemViewModel MailItemViewModel); diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs index 5d22203e..6915420f 100644 --- a/Wino.Mail.WinUI/Services/NavigationService.cs +++ b/Wino.Mail.WinUI/Services/NavigationService.cs @@ -185,6 +185,15 @@ public class NavigationService : NavigationServiceBase, INavigationService { WeakReferenceMessenger.Default.Send(new NewMailItemRenderingRequestedEvent(mailItemViewModel)); } + else if (listingFrame.Content != null + && listingFrame.Content.GetType() == GetPageType(WinoPage.ComposePage) + && page == WinoPage.ComposePage + && parameter is MailItemViewModel composeDraftViewModel) + { + // ComposePage is already active and we're switching to another draft. + // Reuse existing ComposePage and WebView2 instead of navigating. + WeakReferenceMessenger.Default.Send(new NewComposeDraftItemRequestedEvent(composeDraftViewModel)); + } else if (listingFrame.Content != null && listingFrame.Content.GetType() == GetPageType(WinoPage.IdlePage) && pageType == typeof(IdlePage)) diff --git a/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs b/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs index a3b99c69..2e78f3be 100644 --- a/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs @@ -21,6 +21,7 @@ using Wino.Core.Domain; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Models.Reader; using Wino.Mail.ViewModels.Data; +using Wino.Mail.ViewModels.Messages; using Wino.Mail.WinUI.Extensions; using Wino.Messaging.Client.Mails; using Wino.Messaging.Client.Shell; @@ -30,7 +31,8 @@ namespace Wino.Views.Mail; public sealed partial class ComposePage : ComposePageAbstract, IRecipient, - IRecipient + IRecipient, + IRecipient { public WebView2 GetWebView() => WebViewEditor.GetUnderlyingWebView(); @@ -300,6 +302,12 @@ public sealed partial class ComposePage : ComposePageAbstract, WebViewEditor.IsEditorDarkMode = message.IsUnderlyingThemeDark; } + void IRecipient.Receive(NewComposeDraftItemRequestedEvent message) + { + // Reset the initial focus flag so ToBox gets focus for the new draft. + isInitialFocusHandled = false; + } + private void ImportanceClicked(object sender, RoutedEventArgs e) { ImportanceFlyout.Hide(); @@ -415,6 +423,7 @@ public sealed partial class ComposePage : ComposePageAbstract, WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); } protected override void UnregisterRecipients() @@ -423,6 +432,7 @@ public sealed partial class ComposePage : ComposePageAbstract, WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); } // TODO: Save mime on closing the app. diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs index f2cb5458..d05785e1 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs @@ -262,8 +262,9 @@ public sealed partial class MailListPage : MailListPageAbstract, } else if (IsComposingPageActive()) { - // Composer is already active. Prepare composer WebView2 animation. - PrepareComposePageWebViewTransition(); + // Composer is already active. Skip connected animation since the page + // will be reused in-place (no navigation occurs). + // NavigationService will send NewComposeDraftItemRequestedEvent instead. } else composerPageTransition = NavigationTransitionType.DrillIn; diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index e5323775..c571eb2d 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -1056,12 +1056,14 @@ public class MailService : BaseDatabaseService, IMailService bool isIdChanging = localDraftCopy.Id != newMailCopyId; localDraftCopy.Id = newMailCopyId; - localDraftCopy.DraftId = newDraftId; - localDraftCopy.ThreadId = newThreadId; + if (!string.IsNullOrEmpty(newDraftId)) + localDraftCopy.DraftId = newDraftId; + if (!string.IsNullOrEmpty(newThreadId)) + localDraftCopy.ThreadId = newThreadId; await UpdateMailAsync(localDraftCopy).ConfigureAwait(false); - ReportUIChange(new DraftMapped(oldLocalDraftId, newDraftId)); + ReportUIChange(new DraftMapped(oldLocalDraftId, localDraftCopy.DraftId)); return true; } @@ -1070,14 +1072,20 @@ public class MailService : BaseDatabaseService, IMailService { return UpdateAllMailCopiesAsync(mailCopyId, (item) => { - if (item.ThreadId != newThreadId || item.DraftId != newDraftId) + var shouldUpdateThreadId = !string.IsNullOrEmpty(newThreadId); + var shouldUpdateDraftId = !string.IsNullOrEmpty(newDraftId); + + if ((shouldUpdateThreadId && item.ThreadId != newThreadId) || + (shouldUpdateDraftId && item.DraftId != newDraftId)) { var oldDraftId = item.DraftId; - item.DraftId = newDraftId; - item.ThreadId = newThreadId; + if (shouldUpdateDraftId) + item.DraftId = newDraftId; + if (shouldUpdateThreadId) + item.ThreadId = newThreadId; - ReportUIChange(new DraftMapped(oldDraftId, newDraftId)); + ReportUIChange(new DraftMapped(oldDraftId, item.DraftId)); return true; }