Fixing outlook attachments, re-using compose page and some additional fixes on the mime headers for outlook.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.Graph.Models;
|
using Microsoft.Graph.Models;
|
||||||
@@ -138,37 +139,38 @@ public static class OutlookIntegratorExtensions
|
|||||||
var bccAddresses = GetRecipients(mime.Bcc).ToList();
|
var bccAddresses = GetRecipients(mime.Bcc).ToList();
|
||||||
var replyToAddresses = GetRecipients(mime.ReplyTo).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()
|
var message = new Message()
|
||||||
{
|
{
|
||||||
Subject = mime.Subject,
|
Subject = mime.Subject,
|
||||||
Importance = GetImportance(mime.Importance),
|
Importance = GetImportance(mime.Importance),
|
||||||
Body = new ItemBody() { ContentType = BodyType.Html, Content = mime.HtmlBody },
|
Body = new ItemBody() { ContentType = bodyType, Content = bodyContent },
|
||||||
IsDraft = false,
|
IsDraft = false,
|
||||||
IsRead = true, // Sent messages are always read.
|
IsRead = true,
|
||||||
ToRecipients = toAddresses,
|
ToRecipients = toAddresses,
|
||||||
CcRecipients = ccAddresses,
|
CcRecipients = ccAddresses,
|
||||||
BccRecipients = bccAddresses,
|
BccRecipients = bccAddresses,
|
||||||
From = fromAddress,
|
From = fromAddress,
|
||||||
InternetMessageId = mime.MessageId,
|
InternetMessageId = mime.MessageId,
|
||||||
ReplyTo = replyToAddresses,
|
ReplyTo = replyToAddresses,
|
||||||
Attachments = []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set ConversationId if provided to maintain threading
|
|
||||||
if (!string.IsNullOrEmpty(conversationId))
|
if (!string.IsNullOrEmpty(conversationId))
|
||||||
{
|
{
|
||||||
message.ConversationId = conversationId;
|
message.ConversationId = conversationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Headers are only included when creating the draft.
|
// 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)
|
if (includeInternetHeaders)
|
||||||
{
|
{
|
||||||
message.InternetMessageHeaders = GetHeaderList(mime);
|
message.InternetMessageHeaders = GetHeaderList(mime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +369,40 @@ public static class OutlookIntegratorExtensions
|
|||||||
|
|
||||||
#region Mime to Outlook Message Helpers
|
#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)
|
private static IEnumerable<Recipient> GetRecipients(this InternetAddressList internetAddresses)
|
||||||
{
|
{
|
||||||
foreach (var address in internetAddresses)
|
foreach (var address in internetAddresses)
|
||||||
@@ -399,46 +435,46 @@ public static class OutlookIntegratorExtensions
|
|||||||
private static List<InternetMessageHeader> GetHeaderList(this MimeMessage mime)
|
private static List<InternetMessageHeader> GetHeaderList(this MimeMessage mime)
|
||||||
{
|
{
|
||||||
// Graph API only allows max of 5 headers.
|
// Graph API only allows max of 5 headers.
|
||||||
// Here we'll try to ignore some headers that are not neccessary.
|
// Prioritize threading headers to keep reply grouping intact.
|
||||||
// Outlook API will generate them automatically.
|
|
||||||
|
|
||||||
// Some headers also require to start with X- or x-.
|
|
||||||
|
|
||||||
|
const int headerLimit = 5;
|
||||||
string[] headersToIgnore = ["Date", "To", "Cc", "Bcc", "MIME-Version", "From", "Subject", "Message-Id"];
|
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>();
|
var headers = new List<InternetMessageHeader>();
|
||||||
|
|
||||||
// PRIORITY: Always include WinoLocalDraftHeader first if it exists
|
void AddHeader(string name, string value)
|
||||||
// This is critical for draft mapping functionality
|
|
||||||
var winoDraftHeader = mime.Headers.FirstOrDefault(h => h.Field == Domain.Constants.WinoLocalDraftHeader);
|
|
||||||
int includedHeaderCount = 0;
|
|
||||||
|
|
||||||
if (winoDraftHeader != null)
|
|
||||||
{
|
{
|
||||||
var headerValue = winoDraftHeader.Value.Length >= 995 ? winoDraftHeader.Value.Substring(0, 995) : winoDraftHeader.Value;
|
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) return;
|
||||||
headers.Add(new InternetMessageHeader() { Name = winoDraftHeader.Field, Value = headerValue });
|
if (headers.Count >= headerLimit) return;
|
||||||
includedHeaderCount++;
|
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)
|
foreach (var header in mime.Headers)
|
||||||
{
|
{
|
||||||
if (header.Field == Domain.Constants.WinoLocalDraftHeader)
|
if (headers.Count >= headerLimit) break;
|
||||||
continue; // Already processed above
|
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))
|
// Only include custom headers beyond the core threading ones.
|
||||||
{
|
if (!header.Field.StartsWith("X-", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
var headerName = headersToModify.Contains(header.Field) ? $"X-{header.Field}" : header.Field;
|
|
||||||
|
|
||||||
// No header value should exceed 995 characters.
|
AddHeader(header.Field, header.Value);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -36,7 +36,6 @@ using Wino.Core.Domain.Models.Synchronization;
|
|||||||
using Wino.Core.Extensions;
|
using Wino.Core.Extensions;
|
||||||
using Wino.Core.Http;
|
using Wino.Core.Http;
|
||||||
using Wino.Core.Integration.Processors;
|
using Wino.Core.Integration.Processors;
|
||||||
using Wino.Core.Misc;
|
|
||||||
using Wino.Core.Requests.Bundles;
|
using Wino.Core.Requests.Bundles;
|
||||||
using Wino.Core.Requests.Calendar;
|
using Wino.Core.Requests.Calendar;
|
||||||
using Wino.Core.Requests.Folder;
|
using Wino.Core.Requests.Folder;
|
||||||
@@ -45,7 +44,6 @@ using Wino.Core.Requests.Mail;
|
|||||||
namespace Wino.Core.Synchronizers.Mail;
|
namespace Wino.Core.Synchronizers.Mail;
|
||||||
|
|
||||||
[JsonSerializable(typeof(Microsoft.Graph.Me.Messages.Item.Move.MovePostRequestBody))]
|
[JsonSerializable(typeof(Microsoft.Graph.Me.Messages.Item.Move.MovePostRequestBody))]
|
||||||
[JsonSerializable(typeof(OutlookFileAttachment))]
|
|
||||||
public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
|
public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1382,96 +1380,43 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
public override List<IRequestBundle<RequestInformation>> SendDraft(SendDraftRequest request)
|
public override List<IRequestBundle<RequestInformation>> SendDraft(SendDraftRequest request)
|
||||||
{
|
{
|
||||||
var sendDraftPreparationRequest = request.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 mailCopyId = sendDraftPreparationRequest.MailItem.Id;
|
||||||
var mimeMessage = sendDraftPreparationRequest.Mime;
|
var mimeMessage = sendDraftPreparationRequest.Mime;
|
||||||
|
|
||||||
// Convert mime message to Outlook message.
|
// Graph API ignores the From header in direct MIME uploads, so we must convert
|
||||||
// Outlook synchronizer does not send MIME messages directly anymore.
|
// to a JSON Message object to properly support sending from aliases.
|
||||||
// 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
|
|
||||||
var conversationId = sendDraftPreparationRequest.MailItem.ThreadId;
|
var conversationId = sendDraftPreparationRequest.MailItem.ThreadId;
|
||||||
var outlookMessage = mimeMessage.AsOutlookMessage(false, conversationId);
|
var outlookMessage = mimeMessage.AsOutlookMessage(false, conversationId);
|
||||||
|
|
||||||
// Create attachment requests.
|
// Build the request sequence: upload attachments -> patch draft -> send.
|
||||||
// TODO: We need to support large file attachments with sessioned upload at some point.
|
// These execute serially via batch DependsOn (see ConfigureSerialExecution).
|
||||||
|
var attachmentBundles = CreateAttachmentUploadBundles(mimeMessage, mailCopyId);
|
||||||
var attachmentRequestList = CreateAttachmentUploadBundles(mimeMessage, mailCopyId, request).ToList();
|
|
||||||
|
|
||||||
// Update draft.
|
|
||||||
|
|
||||||
var patchDraftRequest = _graphClient.Me.Messages[mailCopyId].ToPatchRequestInformation(outlookMessage);
|
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());
|
return [.. attachmentBundles, patchDraftBundle, sendBundle];
|
||||||
var sendDraftRequestBundle = new HttpRequestBundle<RequestInformation>(sendDraftRequest, request);
|
|
||||||
|
|
||||||
return [.. attachmentRequestList, patchDraftRequestBundle, sendDraftRequestBundle];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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";
|
var uploadRequest = _graphClient.Me.Messages[mailCopyId].Attachments.ToPostRequestInformation(attachment);
|
||||||
|
bundles.Add(new HttpRequestBundle<RequestInformation>(uploadRequest, null));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static RequestInformation PrepareUploadAttachmentRequest(RequestInformation requestInformation, OutlookFileAttachment outlookFileAttachment)
|
return bundles;
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override List<IRequestBundle<RequestInformation>> Archive(BatchArchiveRequest request)
|
public override List<IRequestBundle<RequestInformation>> Archive(BatchArchiveRequest request)
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ using Wino.Core.Domain.Models.Navigation;
|
|||||||
using Wino.Core.Extensions;
|
using Wino.Core.Extensions;
|
||||||
using Wino.Core.Services;
|
using Wino.Core.Services;
|
||||||
using Wino.Mail.ViewModels.Data;
|
using Wino.Mail.ViewModels.Data;
|
||||||
|
using Wino.Mail.ViewModels.Messages;
|
||||||
using Wino.Messaging.Client.Mails;
|
using Wino.Messaging.Client.Mails;
|
||||||
|
|
||||||
namespace Wino.Mail.ViewModels;
|
namespace Wino.Mail.ViewModels;
|
||||||
|
|
||||||
public partial class ComposePageViewModel : MailBaseViewModel
|
public partial class ComposePageViewModel : MailBaseViewModel,
|
||||||
|
IRecipient<NewComposeDraftItemRequestedEvent>
|
||||||
{
|
{
|
||||||
public Func<Task<string>> GetHTMLBodyFunction;
|
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()
|
private async Task<bool> InitializeComposerAccountAsync()
|
||||||
{
|
{
|
||||||
if (CurrentMailDraftItem == null) return false;
|
if (CurrentMailDraftItem == null) return false;
|
||||||
@@ -550,6 +581,8 @@ public partial class ComposePageViewModel : MailBaseViewModel
|
|||||||
{
|
{
|
||||||
if (CurrentMimeMessage == null) return;
|
if (CurrentMimeMessage == null) return;
|
||||||
|
|
||||||
|
IncludedAttachments.Clear();
|
||||||
|
|
||||||
foreach (var attachment in CurrentMimeMessage.Attachments)
|
foreach (var attachment in CurrentMimeMessage.Attachments)
|
||||||
{
|
{
|
||||||
if (attachment.IsAttachment && attachment is MimePart attachmentPart)
|
if (attachment.IsAttachment && attachment is MimePart attachmentPart)
|
||||||
|
|||||||
@@ -635,6 +635,56 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
|
|
||||||
if (isFromDraftOrSentFolder)
|
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)
|
// Only add if the ThreadId exists in the collection (can be threaded with existing items)
|
||||||
if (!ThreadIdExistsInCollection(addedMail)) return;
|
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)
|
private async Task<List<MailItemViewModel>> PrepareMailViewModelsAsync(IEnumerable<MailCopy> mailItems, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Run ViewModel creation on background thread to avoid blocking UI
|
// 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));
|
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
|
else if (listingFrame.Content != null
|
||||||
&& listingFrame.Content.GetType() == GetPageType(WinoPage.IdlePage)
|
&& listingFrame.Content.GetType() == GetPageType(WinoPage.IdlePage)
|
||||||
&& pageType == typeof(IdlePage))
|
&& pageType == typeof(IdlePage))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ using Wino.Core.Domain;
|
|||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Models.Reader;
|
using Wino.Core.Domain.Models.Reader;
|
||||||
using Wino.Mail.ViewModels.Data;
|
using Wino.Mail.ViewModels.Data;
|
||||||
|
using Wino.Mail.ViewModels.Messages;
|
||||||
using Wino.Mail.WinUI.Extensions;
|
using Wino.Mail.WinUI.Extensions;
|
||||||
using Wino.Messaging.Client.Mails;
|
using Wino.Messaging.Client.Mails;
|
||||||
using Wino.Messaging.Client.Shell;
|
using Wino.Messaging.Client.Shell;
|
||||||
@@ -30,7 +31,8 @@ namespace Wino.Views.Mail;
|
|||||||
|
|
||||||
public sealed partial class ComposePage : ComposePageAbstract,
|
public sealed partial class ComposePage : ComposePageAbstract,
|
||||||
IRecipient<CreateNewComposeMailRequested>,
|
IRecipient<CreateNewComposeMailRequested>,
|
||||||
IRecipient<ApplicationThemeChanged>
|
IRecipient<ApplicationThemeChanged>,
|
||||||
|
IRecipient<NewComposeDraftItemRequestedEvent>
|
||||||
{
|
{
|
||||||
public WebView2 GetWebView() => WebViewEditor.GetUnderlyingWebView();
|
public WebView2 GetWebView() => WebViewEditor.GetUnderlyingWebView();
|
||||||
|
|
||||||
@@ -300,6 +302,12 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
|||||||
WebViewEditor.IsEditorDarkMode = message.IsUnderlyingThemeDark;
|
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)
|
private void ImportanceClicked(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
ImportanceFlyout.Hide();
|
ImportanceFlyout.Hide();
|
||||||
@@ -415,6 +423,7 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
|||||||
|
|
||||||
WeakReferenceMessenger.Default.Register<CreateNewComposeMailRequested>(this);
|
WeakReferenceMessenger.Default.Register<CreateNewComposeMailRequested>(this);
|
||||||
WeakReferenceMessenger.Default.Register<ApplicationThemeChanged>(this);
|
WeakReferenceMessenger.Default.Register<ApplicationThemeChanged>(this);
|
||||||
|
WeakReferenceMessenger.Default.Register<NewComposeDraftItemRequestedEvent>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void UnregisterRecipients()
|
protected override void UnregisterRecipients()
|
||||||
@@ -423,6 +432,7 @@ public sealed partial class ComposePage : ComposePageAbstract,
|
|||||||
|
|
||||||
WeakReferenceMessenger.Default.Unregister<CreateNewComposeMailRequested>(this);
|
WeakReferenceMessenger.Default.Unregister<CreateNewComposeMailRequested>(this);
|
||||||
WeakReferenceMessenger.Default.Unregister<ApplicationThemeChanged>(this);
|
WeakReferenceMessenger.Default.Unregister<ApplicationThemeChanged>(this);
|
||||||
|
WeakReferenceMessenger.Default.Unregister<NewComposeDraftItemRequestedEvent>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Save mime on closing the app.
|
// TODO: Save mime on closing the app.
|
||||||
|
|||||||
@@ -262,8 +262,9 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
}
|
}
|
||||||
else if (IsComposingPageActive())
|
else if (IsComposingPageActive())
|
||||||
{
|
{
|
||||||
// Composer is already active. Prepare composer WebView2 animation.
|
// Composer is already active. Skip connected animation since the page
|
||||||
PrepareComposePageWebViewTransition();
|
// will be reused in-place (no navigation occurs).
|
||||||
|
// NavigationService will send NewComposeDraftItemRequestedEvent instead.
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
composerPageTransition = NavigationTransitionType.DrillIn;
|
composerPageTransition = NavigationTransitionType.DrillIn;
|
||||||
|
|||||||
@@ -1056,12 +1056,14 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
bool isIdChanging = localDraftCopy.Id != newMailCopyId;
|
bool isIdChanging = localDraftCopy.Id != newMailCopyId;
|
||||||
|
|
||||||
localDraftCopy.Id = newMailCopyId;
|
localDraftCopy.Id = newMailCopyId;
|
||||||
localDraftCopy.DraftId = newDraftId;
|
if (!string.IsNullOrEmpty(newDraftId))
|
||||||
localDraftCopy.ThreadId = newThreadId;
|
localDraftCopy.DraftId = newDraftId;
|
||||||
|
if (!string.IsNullOrEmpty(newThreadId))
|
||||||
|
localDraftCopy.ThreadId = newThreadId;
|
||||||
|
|
||||||
await UpdateMailAsync(localDraftCopy).ConfigureAwait(false);
|
await UpdateMailAsync(localDraftCopy).ConfigureAwait(false);
|
||||||
|
|
||||||
ReportUIChange(new DraftMapped(oldLocalDraftId, newDraftId));
|
ReportUIChange(new DraftMapped(oldLocalDraftId, localDraftCopy.DraftId));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1070,14 +1072,20 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
{
|
{
|
||||||
return UpdateAllMailCopiesAsync(mailCopyId, (item) =>
|
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;
|
var oldDraftId = item.DraftId;
|
||||||
|
|
||||||
item.DraftId = newDraftId;
|
if (shouldUpdateDraftId)
|
||||||
item.ThreadId = newThreadId;
|
item.DraftId = newDraftId;
|
||||||
|
if (shouldUpdateThreadId)
|
||||||
|
item.ThreadId = newThreadId;
|
||||||
|
|
||||||
ReportUIChange(new DraftMapped(oldDraftId, newDraftId));
|
ReportUIChange(new DraftMapped(oldDraftId, item.DraftId));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user