Fixing outlook attachments, re-using compose page and some additional fixes on the mime headers for outlook.

This commit is contained in:
Burak Kaan Köse
2026-02-07 13:10:57 +01:00
parent 1ec8d5bbf2
commit d28de50ec6
10 changed files with 234 additions and 145 deletions
@@ -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
/// <summary>
/// Extracts all attachments (inline and regular) from a MimeMessage
/// and returns them as Graph SDK FileAttachment objects.
/// </summary>
public static List<FileAttachment> ExtractAttachments(this MimeMessage mime)
{
var attachments = new List<FileAttachment>();
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<Recipient> GetRecipients(this InternetAddressList internetAddresses)
{
foreach (var address in internetAddresses)
@@ -399,46 +435,46 @@ public static class OutlookIntegratorExtensions
private static List<InternetMessageHeader> 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<InternetMessageHeader>();
// 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;
-24
View File
@@ -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; }
}
+20 -75
View File
@@ -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;
/// <summary>
@@ -1382,96 +1380,43 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
public override List<IRequestBundle<RequestInformation>> 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<RequestInformation>(patchDraftRequest, request);
var patchDraftBundle = new HttpRequestBundle<RequestInformation>(patchDraftRequest, request);
// Send draft.
var sendRequest = PreparePostRequestInformation(_graphClient.Me.Messages[mailCopyId].Send.ToPostRequestInformation());
var sendBundle = new HttpRequestBundle<RequestInformation>(sendRequest, request);
var sendDraftRequest = PreparePostRequestInformation(_graphClient.Me.Messages[mailCopyId].Send.ToPostRequestInformation());
var sendDraftRequestBundle = new HttpRequestBundle<RequestInformation>(sendDraftRequest, request);
return [.. attachmentRequestList, patchDraftRequestBundle, sendDraftRequestBundle];
return [.. attachmentBundles, patchDraftBundle, sendBundle];
}
private List<IRequestBundle<RequestInformation>> CreateAttachmentUploadBundles(MimeMessage mime, string mailCopyId, IRequestBase sourceRequest)
/// <summary>
/// Extracts attachments from the MIME message and creates individual
/// Graph API upload requests using the SDK's FileAttachment type.
/// </summary>
private List<IRequestBundle<RequestInformation>> CreateAttachmentUploadBundles(MimeMessage mime, string mailCopyId)
{
var allAttachments = new List<OutlookFileAttachment>();
var attachments = mime.ExtractAttachments();
var bundles = new List<IRequestBundle<RequestInformation>>(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<RequestInformation>(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<IRequestBundle<RequestInformation>>();
// 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<RequestInformation>(modifiedAttachmentUploadRequest, null);
retList.Add(bundle);
}
return retList;
return bundles;
}
public override List<IRequestBundle<RequestInformation>> Archive(BatchArchiveRequest request)
+34 -1
View File
@@ -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<NewComposeDraftItemRequestedEvent>
{
public Func<Task<string>> 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<NewComposeDraftItemRequestedEvent>(this);
}
protected override void UnregisterRecipients()
{
base.UnregisterRecipients();
Messenger.Unregister<NewComposeDraftItemRequestedEvent>(this);
}
private async Task<bool> 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)
@@ -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<List<MailItemViewModel>> PrepareMailViewModelsAsync(IEnumerable<MailCopy> mailItems, CancellationToken cancellationToken = default)
{
// Run ViewModel creation on background thread to avoid blocking UI
@@ -0,0 +1,10 @@
using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.ViewModels.Messages;
/// <summary>
/// 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.
/// </summary>
/// <param name="MailItemViewModel">The new draft mail item to compose.</param>
public record NewComposeDraftItemRequestedEvent(MailItemViewModel MailItemViewModel);
@@ -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))
+11 -1
View File
@@ -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<CreateNewComposeMailRequested>,
IRecipient<ApplicationThemeChanged>
IRecipient<ApplicationThemeChanged>,
IRecipient<NewComposeDraftItemRequestedEvent>
{
public WebView2 GetWebView() => WebViewEditor.GetUnderlyingWebView();
@@ -300,6 +302,12 @@ public sealed partial class ComposePage : ComposePageAbstract,
WebViewEditor.IsEditorDarkMode = message.IsUnderlyingThemeDark;
}
void IRecipient<NewComposeDraftItemRequestedEvent>.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<CreateNewComposeMailRequested>(this);
WeakReferenceMessenger.Default.Register<ApplicationThemeChanged>(this);
WeakReferenceMessenger.Default.Register<NewComposeDraftItemRequestedEvent>(this);
}
protected override void UnregisterRecipients()
@@ -423,6 +432,7 @@ public sealed partial class ComposePage : ComposePageAbstract,
WeakReferenceMessenger.Default.Unregister<CreateNewComposeMailRequested>(this);
WeakReferenceMessenger.Default.Unregister<ApplicationThemeChanged>(this);
WeakReferenceMessenger.Default.Unregister<NewComposeDraftItemRequestedEvent>(this);
}
// TODO: Save mime on closing the app.
@@ -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;
+15 -7
View File
@@ -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;
}