From 4374d19ac2a2af9f8a949210f4b2d31238eefcb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Fri, 6 Feb 2026 20:13:44 +0100 Subject: [PATCH] Threading improvements. --- .../Extensions/MailHeaderExtensions.cs | 42 +++++++++++++++++++ .../Extensions/OutlookIntegratorExtensions.cs | 27 +++++++----- Wino.Core/Synchronizers/GmailSynchronizer.cs | 7 ++-- Wino.Core/Synchronizers/ImapSynchronizer.cs | 7 ++-- Wino.Services/MailService.cs | 24 +++++++---- 5 files changed, 82 insertions(+), 25 deletions(-) create mode 100644 Wino.Core.Domain/Extensions/MailHeaderExtensions.cs diff --git a/Wino.Core.Domain/Extensions/MailHeaderExtensions.cs b/Wino.Core.Domain/Extensions/MailHeaderExtensions.cs new file mode 100644 index 00000000..aa41729b --- /dev/null +++ b/Wino.Core.Domain/Extensions/MailHeaderExtensions.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq; + +namespace Wino.Core.Domain.Extensions; + +public static class MailHeaderExtensions +{ + /// + /// Strips angle brackets from a Message-ID or In-Reply-To value. + /// RFC 5322 Message-IDs are formatted as <id@domain>, but MimeKit + /// properties store them without brackets. This normalizes raw header + /// values to match MimeKit's convention. + /// + public static string StripAngleBrackets(string value) + { + if (string.IsNullOrEmpty(value)) return value; + + value = value.Trim(); + + if (value.StartsWith("<") && value.EndsWith(">")) + return value.Substring(1, value.Length - 2); + + return value; + } + + /// + /// Normalizes a raw RFC References header value into semicolon-separated Message-IDs + /// without angle brackets. Raw References headers contain space-separated bracketed IDs + /// like "<id1@domain> <id2@domain>". This converts them to "id1@domain;id2@domain". + /// + public static string NormalizeReferences(string rawReferences) + { + if (string.IsNullOrEmpty(rawReferences)) return rawReferences; + + var ids = rawReferences + .Split(new[] { ' ', '\t', '\r', '\n', ';', ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(StripAngleBrackets) + .Where(id => !string.IsNullOrEmpty(id)); + + return string.Join(";", ids); + } +} diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 5036f2b8..894b1bce 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -8,6 +8,7 @@ using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Extensions; using Wino.Core.Misc; namespace Wino.Core.Extensions; @@ -64,6 +65,20 @@ public static class OutlookIntegratorExtensions ItemType = MailItemType.Mail // ItemType will be set by caller if calendar access is granted }; + // Extract In-Reply-To and References from InternetMessageHeaders for threading. + if (outlookMessage.InternetMessageHeaders != null) + { + var inReplyToHeader = outlookMessage.InternetMessageHeaders + .FirstOrDefault(h => string.Equals(h.Name, "In-Reply-To", StringComparison.OrdinalIgnoreCase)); + if (inReplyToHeader != null) + mailCopy.InReplyTo = MailHeaderExtensions.StripAngleBrackets(inReplyToHeader.Value); + + var referencesHeader = outlookMessage.InternetMessageHeaders + .FirstOrDefault(h => string.Equals(h.Name, "References", StringComparison.OrdinalIgnoreCase)); + if (referencesHeader != null) + mailCopy.References = MailHeaderExtensions.NormalizeReferences(referencesHeader.Value); + } + if (mailCopy.IsDraft) mailCopy.DraftId = mailCopy.ThreadId; @@ -134,7 +149,7 @@ public static class OutlookIntegratorExtensions CcRecipients = ccAddresses, BccRecipients = bccAddresses, From = fromAddress, - InternetMessageId = GetProperId(mime.MessageId), + InternetMessageId = mime.MessageId, ReplyTo = replyToAddresses, Attachments = [] }; @@ -429,16 +444,6 @@ public static class OutlookIntegratorExtensions return headers; } - private static string GetProperId(string id) - { - // Outlook requires some identifiers to start with "X-" or "x-". - if (string.IsNullOrEmpty(id)) return string.Empty; - - if (!id.StartsWith("x-") || !id.StartsWith("X-")) - return $"X-{id}"; - - return id; - } #endregion diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index b390a9a6..74023de5 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -25,6 +25,7 @@ using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; +using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Folders; @@ -1691,9 +1692,9 @@ public class GmailSynchronizer : WinoSynchronizer h.Name.Equals("In-Reply-To", StringComparison.OrdinalIgnoreCase))?.Value, - MessageId = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Message-Id", StringComparison.OrdinalIgnoreCase))?.Value, - References = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("References", StringComparison.OrdinalIgnoreCase))?.Value, + InReplyTo = MailHeaderExtensions.StripAngleBrackets(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("In-Reply-To", StringComparison.OrdinalIgnoreCase))?.Value), + MessageId = MailHeaderExtensions.StripAngleBrackets(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Message-Id", StringComparison.OrdinalIgnoreCase))?.Value), + References = MailHeaderExtensions.NormalizeReferences(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("References", StringComparison.OrdinalIgnoreCase))?.Value), FileId = Guid.NewGuid(), ItemType = itemType }; diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index 2fc17dbf..1ac92063 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -196,6 +196,9 @@ public class ImapSynchronizer : WinoSynchronizer(); - if (!string.IsNullOrEmpty(draftCreationOptions.ReferencedMessage.MimeMessage.MessageId)) - copy.InReplyTo = draftCreationOptions.ReferencedMessage.MimeMessage.MessageId; + if (refMime.References != null) + refs.AddRange(refMime.References); + + if (!string.IsNullOrEmpty(refMime.MessageId)) + { + copy.InReplyTo = refMime.MessageId; + refs.Add(refMime.MessageId); + } + + if (refs.Count > 0) + copy.References = string.Join(";", refs); if (!string.IsNullOrEmpty(draftCreationOptions.ReferencedMessage.MailCopy?.ThreadId)) copy.ThreadId = draftCreationOptions.ReferencedMessage.MailCopy.ThreadId; @@ -960,8 +970,8 @@ public class MailService : BaseDatabaseService, IMailService if (!string.IsNullOrEmpty(referenceMailCopy.References)) { - // Parse the References string and add them - var references = referenceMailCopy.References.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + // 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());