Improving thread mapping for all synchronizers.

This commit is contained in:
Burak Kaan Köse
2026-02-23 01:51:44 +01:00
parent c5a631da6f
commit 79a81710f0
7 changed files with 314 additions and 60 deletions
@@ -131,6 +131,12 @@ public interface IMailService
/// <returns>Draft MailCopy and Draft MimeMessage as base64.</returns>
Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions);
/// <summary>
/// Finds a mail copy in the given account by RFC Message-Id.
/// Returns null when no local match exists.
/// </summary>
Task<MailCopy> GetMailCopyByMessageIdAsync(Guid accountId, string messageId);
/// <summary>
/// Returns ids
/// </summary>
@@ -438,7 +438,9 @@ public static class OutlookIntegratorExtensions
private static List<InternetMessageHeader> GetHeaderList(this MimeMessage mime)
{
// Graph API only allows max of 5 headers.
// Prioritize threading headers to keep reply grouping intact.
// Graph only allows setting custom internet headers (typically X-*).
// Reply/threading headers like In-Reply-To and References are managed by
// createReply/createReplyAll flows and must not be sent here.
const int headerLimit = 5;
string[] headersToIgnore = ["Date", "To", "Cc", "Bcc", "MIME-Version", "From", "Subject", "Message-Id"];
@@ -461,18 +463,12 @@ public static class OutlookIntegratorExtensions
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 (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;
// Only include custom headers beyond the core threading ones.
if (!header.Field.StartsWith("X-", StringComparison.OrdinalIgnoreCase)) continue;
@@ -1183,6 +1183,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
message.ThreadId = singleDraftRequest.Item.ThreadId;
}
// Local draft mapping header must never leak to recipients.
singleDraftRequest.Request.Mime.Headers.Remove(Domain.Constants.WinoLocalDraftHeader);
singleDraftRequest.Request.Mime.Prepare(EncodingConstraint.None);
var mimeString = singleDraftRequest.Request.Mime.ToString();
@@ -1812,6 +1812,10 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
{
await HandleFailedResponseAsync(bundle, response, errors);
}
else
{
await HandleSuccessfulResponseAsync(bundle, response).ConfigureAwait(false);
}
}
}
@@ -1859,6 +1863,39 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
}
}
private async Task HandleSuccessfulResponseAsync(IRequestBundle<RequestInformation> bundle, HttpResponseMessage response)
{
if (bundle?.UIChangeRequest is not CreateDraftRequest createDraftRequest)
return;
try
{
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(content))
return;
var json = JsonNode.Parse(content);
var createdDraftId = json?["id"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(createdDraftId))
return;
var createdConversationId = json?["conversationId"]?.GetValue<string>();
var localDraft = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftCopy;
await _outlookChangeProcessor.MapLocalDraftAsync(
Account.Id,
localDraft.UniqueId,
createdDraftId,
createdConversationId,
createdConversationId).ConfigureAwait(false);
}
catch (Exception ex)
{
// Draft mapping is best-effort here. Delta sync mapping remains as fallback.
_logger.Debug(ex, "Failed to map Outlook draft from create-draft response.");
}
}
private void ThrowBatchExecutionException(List<string> errors)
{
var formattedErrorString = string.Join("\n",
+34 -5
View File
@@ -327,10 +327,7 @@ public partial class ComposePageViewModel : MailBaseViewModel,
using MemoryStream memoryStream = new();
CurrentMimeMessage.WriteTo(FormatOptions.Default, memoryStream);
byte[] buffer = memoryStream.GetBuffer();
int count = (int)memoryStream.Length;
var base64EncodedMessage = Convert.ToBase64String(buffer);
var base64EncodedMessage = Convert.ToBase64String(memoryStream.ToArray());
var draftSendPreparationRequest = new SendDraftPreparationRequest(CurrentMailDraftItem.MailCopy,
SelectedAlias,
sentFolder,
@@ -364,11 +361,13 @@ public partial class ComposePageViewModel : MailBaseViewModel,
await UpdateMimeChangesAsync().ConfigureAwait(false);
var localDraftCopy = CurrentMailDraftItem.MailCopy;
var (retryReason, referenceMailCopy) = await ResolveRetryDraftContextAsync().ConfigureAwait(false);
var draftPreparationRequest = new DraftPreparationRequest(
localDraftCopy.AssignedAccount ?? ComposingAccount,
localDraftCopy,
CurrentMimeMessage.GetBase64MimeMessage(),
DraftCreationReason.Empty);
retryReason,
referenceMailCopy);
await _worker.ExecuteAsync(draftPreparationRequest).ConfigureAwait(false);
}
@@ -384,7 +383,11 @@ public partial class ComposePageViewModel : MailBaseViewModel,
});
await UpdatePendingOperationStateAsync().ConfigureAwait(false);
await ExecuteUIThread(() =>
{
NotifyComposeActionStateChanged();
});
}
}
@@ -785,6 +788,32 @@ public partial class ComposePageViewModel : MailBaseViewModel,
list.Add(new MailboxAddress(item.Name, item.Address));
}
private async Task<(DraftCreationReason reason, MailCopy referenceMailCopy)> ResolveRetryDraftContextAsync()
{
if (CurrentMimeMessage == null || CurrentMailDraftItem?.MailCopy?.AssignedAccount == null)
return (DraftCreationReason.Empty, null);
var inReplyTo = CurrentMimeMessage.InReplyTo;
if (string.IsNullOrWhiteSpace(inReplyTo) && CurrentMimeMessage.Headers.Contains(HeaderId.InReplyTo))
inReplyTo = CurrentMimeMessage.Headers[HeaderId.InReplyTo];
inReplyTo = MailHeaderExtensions.StripAngleBrackets(inReplyTo);
if (string.IsNullOrWhiteSpace(inReplyTo))
return (DraftCreationReason.Empty, null);
var accountId = CurrentMailDraftItem.MailCopy.AssignedAccount.Id;
var referenceMailCopy = await _mailService.GetMailCopyByMessageIdAsync(accountId, inReplyTo).ConfigureAwait(false);
if (referenceMailCopy == null)
return (DraftCreationReason.Empty, null);
// We cannot perfectly reconstruct original intent (Reply vs ReplyAll) from persisted data.
// Infer ReplyAll when multiple recipients exist on the local MIME.
var totalRecipients = CurrentMimeMessage.To.Mailboxes.Count() + CurrentMimeMessage.Cc.Mailboxes.Count();
var reason = totalRecipients > 1 ? DraftCreationReason.ReplyAll : DraftCreationReason.Reply;
return (reason, referenceMailCopy);
}
public async Task<AccountContact> GetAddressInformationAsync(string tokenText, ObservableCollection<AccountContact> collection)
{
// Get model from the service. This will make sure the name is properly included if there is any record.
@@ -114,6 +114,7 @@ public static class MailkitClientExtensions
var fromAddress = mime != null ? GetActualSenderAddress(mime) : GetEnvelopeSenderAddress(envelope);
var references = mime?.References?.GetReferences() ?? messageSummary.References?.GetReferences();
var inReplyTo = mime != null ? mime.GetInReplyTo() : envelope?.InReplyTo ?? string.Empty;
var threadId = ResolveThreadId(messageSummary, messageId, references, inReplyTo);
var hasAttachments = mime != null ? mime.Attachments.Any() : false;
var itemType = mime != null ? GetMailItemTypeFromMime(mime) : MailItemType.Mail;
@@ -121,7 +122,7 @@ public static class MailkitClientExtensions
{
Id = messageUid,
CreationDate = creationDate,
ThreadId = messageSummary.GetThreadId(),
ThreadId = threadId,
MessageId = messageId,
Subject = subject,
IsRead = messageSummary.Flags.GetIsRead(),
@@ -141,6 +142,48 @@ public static class MailkitClientExtensions
return copy;
}
private static string ResolveThreadId(IMessageSummary messageSummary, string messageId, string references, string inReplyTo)
{
var serverThreadId = messageSummary.GetThreadId();
if (!string.IsNullOrEmpty(serverThreadId))
return serverThreadId;
// Fallback threading for IMAP providers that do not expose a native ThreadId:
// - Prefer root of References chain
// - Then In-Reply-To
// - Finally own Message-Id (single-message thread root)
var rootReference = references?
.Split(new[] { ';', ',', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(NormalizeThreadToken)
.FirstOrDefault();
if (!string.IsNullOrEmpty(rootReference))
return rootReference;
var normalizedInReplyTo = NormalizeThreadToken(inReplyTo);
if (!string.IsNullOrEmpty(normalizedInReplyTo))
return normalizedInReplyTo;
var normalizedMessageId = NormalizeThreadToken(messageId);
if (!string.IsNullOrEmpty(normalizedMessageId))
return normalizedMessageId;
return string.Empty;
}
private static string NormalizeThreadToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = value.Trim();
if (value.StartsWith("<") && value.EndsWith(">") && value.Length > 2)
value = value.Substring(1, value.Length - 2);
return value;
}
private static string GetPreviewText(IMessageSummary messageSummary, string subjectFallback)
{
if (!string.IsNullOrWhiteSpace(messageSummary.PreviewText))
+186 -46
View File
@@ -91,22 +91,33 @@ public class MailService : BaseDatabaseService, IMailService
if (draftCreationOptions.ReferencedMessage != null)
{
var refMime = draftCreationOptions.ReferencedMessage.MimeMessage;
var refs = new List<string>();
var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
if (refMime.References != null)
refs.AddRange(refMime.References);
string referenceMessageId = refMime?.MessageId;
string referenceInReplyTo = refMime?.InReplyTo;
IEnumerable<string> referenceChain = refMime?.References ?? [];
if (!string.IsNullOrEmpty(refMime.MessageId))
// Fallback to MailCopy metadata if MIME lacks threading headers.
if (string.IsNullOrWhiteSpace(referenceMessageId) && referenceMailCopy != null)
{
copy.InReplyTo = refMime.MessageId;
refs.Add(refMime.MessageId);
referenceMessageId = referenceMailCopy.MessageId;
referenceInReplyTo = referenceMailCopy.InReplyTo;
referenceChain = SplitStoredReferences(referenceMailCopy.References);
}
if (!string.IsNullOrWhiteSpace(referenceMessageId))
copy.InReplyTo = MailHeaderExtensions.StripAngleBrackets(referenceMessageId);
var refs = BuildReferencesChain(referenceChain, referenceInReplyTo, referenceMessageId);
if (refs.Count > 0)
copy.References = string.Join(";", refs);
if (!string.IsNullOrEmpty(draftCreationOptions.ReferencedMessage.MailCopy?.ThreadId))
copy.ThreadId = draftCreationOptions.ReferencedMessage.MailCopy.ThreadId;
// Fallback local threading when provider/native thread id is unavailable.
if (string.IsNullOrWhiteSpace(copy.ThreadId))
copy.ThreadId = refs.FirstOrDefault() ?? copy.InReplyTo;
}
await Connection.InsertAsync(copy, typeof(MailCopy));
@@ -154,6 +165,26 @@ public class MailService : BaseDatabaseService, IMailService
return unreadMails;
}
public async Task<MailCopy> GetMailCopyByMessageIdAsync(Guid accountId, string messageId)
{
var normalizedMessageId = MailHeaderExtensions.StripAngleBrackets(messageId)?.Trim();
if (string.IsNullOrWhiteSpace(normalizedMessageId))
return null;
var mailCopy = await Connection.FindWithQueryAsync<MailCopy>(
"SELECT MailCopy.* FROM MailCopy " +
"INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id " +
"WHERE MailItemFolder.MailAccountId = ? AND MailCopy.MessageId = ? " +
"ORDER BY MailCopy.IsDraft ASC, MailCopy.CreationDate DESC LIMIT 1",
accountId,
normalizedMessageId).ConfigureAwait(false);
if (mailCopy != null)
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
return mailCopy;
}
private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options)
{
var sql = new StringBuilder();
@@ -925,11 +956,12 @@ public class MailService : BaseDatabaseService, IMailService
var builder = new BodyBuilder();
var signature = await GetSignature(account, draftCreationOptions.Reason);
var ownAddresses = await GetOwnAddressesAsync(account).ConfigureAwait(false);
_ = draftCreationOptions.Reason switch
{
DraftCreationReason.Empty => CreateEmptyDraft(builder, message, draftCreationOptions, signature),
_ => CreateReferencedDraft(builder, message, draftCreationOptions, account, signature),
_ => CreateReferencedDraft(builder, message, draftCreationOptions, signature, ownAddresses),
};
// TODO: Migration
@@ -996,10 +1028,15 @@ public class MailService : BaseDatabaseService, IMailService
return message;
}
private MimeMessage CreateReferencedDraft(BodyBuilder builder, MimeMessage message, DraftCreationOptions draftCreationOptions, MailAccount account, string signature)
private MimeMessage CreateReferencedDraft(BodyBuilder builder,
MimeMessage message,
DraftCreationOptions draftCreationOptions,
string signature,
ISet<string> ownAddresses)
{
var reason = draftCreationOptions.Reason;
var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage;
ownAddresses ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var gap = CreateHtmlGap();
builder.HtmlBody = gap + CreateHtmlForReferencingMessage(referenceMessage);
@@ -1012,44 +1049,88 @@ public class MailService : BaseDatabaseService, IMailService
// Manage "To"
if (reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll)
{
// Reply to the sender of the message
if (referenceMessage.ReplyTo.Count > 0)
message.To.AddRange(referenceMessage.ReplyTo);
else if (referenceMessage.From.Count > 0)
message.To.AddRange(referenceMessage.From);
else if (referenceMessage.Sender != null)
message.To.Add(referenceMessage.Sender);
var toRecipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var ccRecipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddToRecipient(MailboxAddress mailbox, bool allowSelf)
{
var address = mailbox?.Address?.Trim();
if (string.IsNullOrWhiteSpace(address))
return;
if (!allowSelf && ownAddresses.Contains(address))
return;
if (!toRecipients.Add(address))
return;
message.To.Add(new MailboxAddress(mailbox.Name, address));
}
void AddCcRecipient(MailboxAddress mailbox, bool allowSelf)
{
var address = mailbox?.Address?.Trim();
if (string.IsNullOrWhiteSpace(address))
return;
if (!allowSelf && ownAddresses.Contains(address))
return;
if (toRecipients.Contains(address) || !ccRecipients.Add(address))
return;
message.Cc.Add(new MailboxAddress(mailbox.Name, address));
}
// Reply target follows Reply-To first, then From, then Sender.
if (referenceMessage.ReplyTo.Mailboxes.Any())
{
foreach (var mailbox in referenceMessage.ReplyTo.Mailboxes)
AddToRecipient(mailbox, allowSelf: true);
}
else if (referenceMessage.From.Mailboxes.Any())
{
foreach (var mailbox in referenceMessage.From.Mailboxes)
AddToRecipient(mailbox, allowSelf: true);
}
else if (referenceMessage.Sender is MailboxAddress senderMailbox)
{
AddToRecipient(senderMailbox, allowSelf: true);
}
if (reason == DraftCreationReason.ReplyAll)
{
// Include all of the other original recipients
message.To.AddRange(referenceMessage.To.Where(x => x is MailboxAddress mailboxAddress && !mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase)));
message.Cc.AddRange(referenceMessage.Cc.Where(x => x is MailboxAddress mailboxAddress && !mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase)));
foreach (var mailbox in referenceMessage.To.Mailboxes)
AddToRecipient(mailbox, allowSelf: false);
foreach (var mailbox in referenceMessage.Cc.Mailboxes)
AddCcRecipient(mailbox, allowSelf: false);
}
// Self email can be present at this step, when replying to own message. It should be removed only in case there no other recipients.
if (message.To.Count > 1)
if (message.To.Mailboxes.Count() > 1)
{
var selfRecipients = message.To.Mailboxes
.Where(m => ownAddresses.Contains(m.Address ?? string.Empty))
.ToList();
foreach (var self in selfRecipients)
{
var self = message.To.FirstOrDefault(x => x is MailboxAddress mailboxAddress && mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase));
if (self != null)
message.To.Remove(self);
}
}
// Manage "ThreadId-ConversationId"
// CRITICAL: In-Reply-To and References headers are essential for threading
// They must reference the original message's Message-ID from the MIME headers
if (!string.IsNullOrEmpty(referenceMessage.MessageId))
{
message.InReplyTo = referenceMessage.MessageId;
message.InReplyTo = MailHeaderExtensions.StripAngleBrackets(referenceMessage.MessageId);
// Add all previous References first
if (referenceMessage.References != null && referenceMessage.References.Count > 0)
{
message.References.AddRange(referenceMessage.References);
}
var refs = BuildReferencesChain(
referenceMessage.References,
referenceMessage.InReplyTo,
referenceMessage.MessageId);
// Then add the message we're replying to
message.References.Add(referenceMessage.MessageId);
foreach (var referenceId in refs)
message.References.Add(referenceId);
}
else
{
@@ -1058,32 +1139,30 @@ public class MailService : BaseDatabaseService, IMailService
var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
if (referenceMailCopy != null && !string.IsNullOrEmpty(referenceMailCopy.MessageId))
{
message.InReplyTo = referenceMailCopy.MessageId;
message.InReplyTo = MailHeaderExtensions.StripAngleBrackets(referenceMailCopy.MessageId);
if (!string.IsNullOrEmpty(referenceMailCopy.References))
{
// Parse the References string (supports both ";" and "," separators for backward compatibility)
var references = referenceMailCopy.References.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var reference in references)
{
message.References.Add(reference.Trim());
}
}
message.References.Add(referenceMailCopy.MessageId);
var refs = BuildReferencesChain(
SplitStoredReferences(referenceMailCopy.References),
referenceMailCopy.InReplyTo,
referenceMailCopy.MessageId);
foreach (var referenceId in refs)
message.References.Add(referenceId);
}
}
if (!string.IsNullOrEmpty(referenceMessage.Subject))
message.Headers.Add("Thread-Topic", referenceMessage.Subject);
}
// Manage Subject
if (reason == DraftCreationReason.Forward && !referenceMessage.Subject.StartsWith("FW: ", StringComparison.OrdinalIgnoreCase))
message.Subject = $"FW: {referenceMessage.Subject}";
else if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) && !referenceMessage.Subject.StartsWith("RE: ", StringComparison.OrdinalIgnoreCase))
message.Subject = $"RE: {referenceMessage.Subject}";
else if (referenceMessage != null)
message.Subject = referenceMessage.Subject;
var referenceSubject = referenceMessage?.Subject ?? string.Empty;
if (reason == DraftCreationReason.Forward && !referenceSubject.StartsWith("FW: ", StringComparison.OrdinalIgnoreCase))
message.Subject = $"FW: {referenceSubject}";
else if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) && !referenceSubject.StartsWith("RE: ", StringComparison.OrdinalIgnoreCase))
message.Subject = $"RE: {referenceSubject}";
else
message.Subject = referenceSubject;
// Only include attachments if forwarding.
if (reason == DraftCreationReason.Forward && (referenceMessage?.Attachments?.Any() ?? false))
@@ -1245,6 +1324,67 @@ public class MailService : BaseDatabaseService, IMailService
return new GmailArchiveComparisonResult(addedMails, removedMails);
}
private async Task<HashSet<string>> GetOwnAddressesAsync(MailAccount account)
{
var ownAddresses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(account?.Address))
ownAddresses.Add(account.Address.Trim());
var aliases = await _accountService.GetAccountAliasesAsync(account.Id).ConfigureAwait(false);
if (aliases != null)
{
foreach (var alias in aliases)
{
if (!string.IsNullOrWhiteSpace(alias?.AliasAddress))
ownAddresses.Add(alias.AliasAddress.Trim());
}
}
return ownAddresses;
}
private static IEnumerable<string> SplitStoredReferences(string references)
{
if (string.IsNullOrWhiteSpace(references))
return [];
return references
.Split(new[] { ';', ',', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(r => r.Trim());
}
private static List<string> BuildReferencesChain(IEnumerable<string> existingReferences, string parentInReplyTo, string parentMessageId)
{
var results = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddReference(string value)
{
var normalized = MailHeaderExtensions.StripAngleBrackets(value)?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
return;
if (!seen.Add(normalized))
return;
results.Add(normalized);
}
if (existingReferences != null)
{
foreach (var reference in existingReferences)
AddReference(reference);
}
// RFC 5322 fallback: if References is absent, include parent In-Reply-To first when available.
if (results.Count == 0)
AddReference(parentInReplyTo);
AddReference(parentMessageId);
return results;
}
public async Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
{
var recentMails = await Connection.Table<MailCopy>()