diff --git a/Wino.Core.Domain/Extensions/MailHeaderExtensions.cs b/Wino.Core.Domain/Extensions/MailHeaderExtensions.cs index aa41729b..2b686ff7 100644 --- a/Wino.Core.Domain/Extensions/MailHeaderExtensions.cs +++ b/Wino.Core.Domain/Extensions/MailHeaderExtensions.cs @@ -1,10 +1,26 @@ using System; +using System.Collections.Generic; using System.Linq; namespace Wino.Core.Domain.Extensions; public static class MailHeaderExtensions { + public static string NormalizeMessageId(string value) + { + if (value == null) + return null; + + var normalized = StripAngleBrackets(value)?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? string.Empty : normalized; + } + + public static string ToHeaderMessageId(string value) + { + var normalized = NormalizeMessageId(value); + return string.IsNullOrEmpty(normalized) ? string.Empty : $"<{normalized}>"; + } + /// /// Strips angle brackets from a Message-ID or In-Reply-To value. /// RFC 5322 Message-IDs are formatted as <id@domain>, but MimeKit @@ -29,14 +45,53 @@ public static class MailHeaderExtensions /// like "<id1@domain> <id2@domain>". This converts them to "id1@domain;id2@domain". /// public static string NormalizeReferences(string rawReferences) + => JoinStoredReferences(SplitMessageIds(rawReferences)); + + public static IEnumerable SplitMessageIds(string values) { - if (string.IsNullOrEmpty(rawReferences)) return rawReferences; + if (string.IsNullOrWhiteSpace(values)) + return []; - var ids = rawReferences + return values .Split(new[] { ' ', '\t', '\r', '\n', ';', ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(StripAngleBrackets) + .Select(NormalizeMessageId) .Where(id => !string.IsNullOrEmpty(id)); + } - return string.Join(";", ids); + public static string JoinStoredReferences(IEnumerable values) + => string.Join(";", NormalizeDistinctMessageIds(values)); + + public static string BuildReferencesHeaderValue(IEnumerable values) + => string.Join(" ", NormalizeDistinctMessageIds(values).Select(ToHeaderMessageId)); + + public static List BuildReferencesChain(IEnumerable existingReferences, string parentMessageId) + { + var results = NormalizeDistinctMessageIds(existingReferences).ToList(); + var normalizedParentMessageId = NormalizeMessageId(parentMessageId); + + if (!string.IsNullOrEmpty(normalizedParentMessageId) && + !results.Contains(normalizedParentMessageId, StringComparer.OrdinalIgnoreCase)) + { + results.Add(normalizedParentMessageId); + } + + return results; + } + + private static IEnumerable NormalizeDistinctMessageIds(IEnumerable values) + { + if (values == null) + yield break; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var value in values) + { + var normalized = NormalizeMessageId(value); + if (string.IsNullOrEmpty(normalized) || !seen.Add(normalized)) + continue; + + yield return normalized; + } } } diff --git a/Wino.Core.Domain/Misc/MessageIdGenerator.cs b/Wino.Core.Domain/Misc/MessageIdGenerator.cs new file mode 100644 index 00000000..36e6d72e --- /dev/null +++ b/Wino.Core.Domain/Misc/MessageIdGenerator.cs @@ -0,0 +1,13 @@ +using System; + +namespace Wino.Core.Domain.Misc; + +public static class MessageIdGenerator +{ + private const string Domain = "wino-mail.app"; + + public static string Generate() + { + return $"<{Guid.NewGuid()}@{Domain}>"; + } +} diff --git a/Wino.Core.Tests/Services/MailHeaderExtensionsTests.cs b/Wino.Core.Tests/Services/MailHeaderExtensionsTests.cs new file mode 100644 index 00000000..776b93b4 --- /dev/null +++ b/Wino.Core.Tests/Services/MailHeaderExtensionsTests.cs @@ -0,0 +1,27 @@ +using FluentAssertions; +using Wino.Core.Domain.Extensions; +using Wino.Core.Domain.Misc; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public class MailHeaderExtensionsTests +{ + [Fact] + public void MessageIdGenerator_Generate_ReturnsGuidAtWinoMailDomain() + { + var generated = MessageIdGenerator.Generate(); + + generated.Should().MatchRegex("^<[0-9a-fA-F-]{36}@wino-mail\\.app>$"); + } + + [Fact] + public void BuildReferencesChain_DeduplicatesAndAppendsParentMessageId() + { + var chain = MailHeaderExtensions.BuildReferencesChain( + ["", "middle@domain.com", ""], + ""); + + chain.Should().Equal("root@domain.com", "middle@domain.com", "parent@domain.com"); + } +} diff --git a/Wino.Core.Tests/Services/MailThreadingTests.cs b/Wino.Core.Tests/Services/MailThreadingTests.cs new file mode 100644 index 00000000..cec68654 --- /dev/null +++ b/Wino.Core.Tests/Services/MailThreadingTests.cs @@ -0,0 +1,243 @@ +using FluentAssertions; +using MimeKit; +using Moq; +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.Domain.Interfaces; +using Wino.Core.Domain.Models.MailItem; +using Wino.Core.Tests.Helpers; +using Wino.Services; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public class MailThreadingTests : IAsyncLifetime +{ + private InMemoryDatabaseService _databaseService = null!; + private MailService _mailService = null!; + private MailAccount _account = null!; + private MailItemFolder _draftFolder = null!; + + public async Task InitializeAsync() + { + _databaseService = new InMemoryDatabaseService(); + await _databaseService.InitializeAsync(); + + _account = new MailAccount + { + Id = Guid.NewGuid(), + Name = "Threading Test Account", + Address = "me@test.local", + SenderName = "Test User", + ProviderType = MailProviderType.IMAP4 + }; + + _draftFolder = new MailItemFolder + { + Id = Guid.NewGuid(), + MailAccountId = _account.Id, + FolderName = "Drafts", + SpecialFolderType = SpecialFolderType.Draft, + IsSystemFolder = true, + IsSynchronizationEnabled = true + }; + + var preferences = new MailAccountPreferences + { + Id = Guid.NewGuid(), + AccountId = _account.Id, + IsNotificationsEnabled = true, + IsSignatureEnabled = false + }; + + var alias = new MailAccountAlias + { + Id = Guid.NewGuid(), + AccountId = _account.Id, + AliasAddress = _account.Address, + ReplyToAddress = _account.Address, + IsPrimary = true, + IsRootAlias = true, + IsVerified = true + }; + + await _databaseService.Connection.InsertAsync(_account, typeof(MailAccount)); + await _databaseService.Connection.InsertAsync(_draftFolder, typeof(MailItemFolder)); + await _databaseService.Connection.InsertAsync(preferences, typeof(MailAccountPreferences)); + await _databaseService.Connection.InsertAsync(alias, typeof(MailAccountAlias)); + + _mailService = BuildMailService(_databaseService); + } + + public async Task DisposeAsync() => await _databaseService.DisposeAsync(); + + [Fact] + public async Task CreateDraftAsync_EmptyDraft_AssignsGeneratedMessageId() + { + var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync( + _account.Id, + new DraftCreationOptions { Reason = DraftCreationReason.Empty }); + + var mimeMessage = draftBase64MimeMessage.GetMimeMessageFromBase64(); + + draftMailCopy.MessageId.Should().MatchRegex("^[0-9a-fA-F-]{36}@wino-mail\\.app$"); + mimeMessage.MessageId.Should().Be(draftMailCopy.MessageId); + mimeMessage.Headers[HeaderId.MessageId].Should().Be(MailHeaderExtensions.ToHeaderMessageId(draftMailCopy.MessageId)); + } + + [Fact] + public async Task CreateDraftAsync_Reply_SetsInReplyToReferencesAndReplySubject() + { + const string parentMessageId = "original@domain.com"; + + var referencedMimeMessage = CreateReferencedMimeMessage("From outlook", parentMessageId); + var referencedMailCopy = new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + ThreadId = "provider-thread-id", + MessageId = parentMessageId + }; + + var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync( + _account.Id, + new DraftCreationOptions + { + Reason = DraftCreationReason.Reply, + ReferencedMessage = new ReferencedMessage + { + MimeMessage = referencedMimeMessage, + MailCopy = referencedMailCopy + } + }); + + var mimeMessage = draftBase64MimeMessage.GetMimeMessageFromBase64(); + + draftMailCopy.InReplyTo.Should().Be(parentMessageId); + draftMailCopy.References.Should().Be(parentMessageId); + draftMailCopy.Subject.Should().Be("Re: From outlook"); + draftMailCopy.ThreadId.Should().Be(referencedMailCopy.ThreadId); + + mimeMessage.InReplyTo.Should().Be(parentMessageId); + MailHeaderExtensions.NormalizeReferences(mimeMessage.Headers[HeaderId.References]).Should().Be(parentMessageId); + } + + [Fact] + public async Task CreateDraftAsync_Reply_AppendsReferencesChainOnce() + { + const string rootMessageId = "root@domain.com"; + const string middleMessageId = "middle@domain.com"; + const string parentMessageId = "parent@domain.com"; + + var referencedMimeMessage = CreateReferencedMimeMessage("Re: Existing subject", parentMessageId); + referencedMimeMessage.References.Add(rootMessageId); + referencedMimeMessage.References.Add(middleMessageId); + + var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync( + _account.Id, + new DraftCreationOptions + { + Reason = DraftCreationReason.Reply, + ReferencedMessage = new ReferencedMessage + { + MimeMessage = referencedMimeMessage, + MailCopy = new MailCopy { UniqueId = Guid.NewGuid(), Id = Guid.NewGuid().ToString(), MessageId = parentMessageId } + } + }); + + var mimeMessage = draftBase64MimeMessage.GetMimeMessageFromBase64(); + + draftMailCopy.References.Should().Be($"{rootMessageId};{middleMessageId};{parentMessageId}"); + draftMailCopy.Subject.Should().Be("Re: Existing subject"); + MailHeaderExtensions.NormalizeReferences(mimeMessage.Headers[HeaderId.References]) + .Should().Be($"{rootMessageId};{middleMessageId};{parentMessageId}"); + } + + [Fact] + public async Task CreateDraftAsync_Reply_FallsBackToReferencedMailCopyThreadingMetadata() + { + const string rootMessageId = "root@domain.com"; + const string parentMessageId = "copy-parent@domain.com"; + + var referencedMimeMessage = CreateReferencedMimeMessage("Fallback subject"); + referencedMimeMessage.Headers.Remove(HeaderId.MessageId); + + var referencedMailCopy = new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = Guid.NewGuid().ToString(), + MessageId = parentMessageId, + References = rootMessageId + }; + + var (draftMailCopy, _) = await _mailService.CreateDraftAsync( + _account.Id, + new DraftCreationOptions + { + Reason = DraftCreationReason.Reply, + ReferencedMessage = new ReferencedMessage + { + MimeMessage = referencedMimeMessage, + MailCopy = referencedMailCopy + } + }); + + draftMailCopy.InReplyTo.Should().Be(parentMessageId); + draftMailCopy.References.Should().Be($"{rootMessageId};{parentMessageId}"); + } + + private static MimeMessage CreateReferencedMimeMessage(string subject, string? messageId = null) + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress("Sender", "sender@example.com")); + message.To.Add(new MailboxAddress("Recipient", "recipient@example.com")); + message.Subject = subject; + message.Body = new TextPart("plain") { Text = "Body" }; + + if (!string.IsNullOrWhiteSpace(messageId)) + message.MessageId = messageId; + + return message; + } + + private static MailService BuildMailService(InMemoryDatabaseService db) + { + var signatureService = new Mock(); + var authProvider = new Mock(); + var mimeFileService = new Mock(); + mimeFileService + .Setup(x => x.SaveMimeMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + mimeFileService + .Setup(x => x.CreateHTMLPreviewVisitor(It.IsAny(), It.IsAny())) + .Returns((_, _) => new HtmlPreviewVisitor(string.Empty)); + + var preferencesService = new Mock(); + preferencesService.SetupProperty(x => x.ComposerFont, "Calibri"); + preferencesService.SetupProperty(x => x.ComposerFontSize, 12); + + var contactPictureFileService = new Mock(); + + var accountService = new AccountService( + db, + signatureService.Object, + authProvider.Object, + mimeFileService.Object, + preferencesService.Object, + contactPictureFileService.Object); + + var folderService = new FolderService(db, accountService); + var contactService = new ContactService(db); + + return new MailService( + db, + folderService, + contactService, + accountService, + signatureService.Object, + mimeFileService.Object, + preferencesService.Object); + } +} diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 4bfdf542..8ec1407f 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -48,7 +48,7 @@ public static class OutlookIntegratorExtensions var mailCopy = new MailCopy() { - MessageId = outlookMessage.InternetMessageId, + MessageId = MailHeaderExtensions.NormalizeMessageId(outlookMessage.InternetMessageId), IsFlagged = GetIsFlagged(outlookMessage), IsFocused = GetIsFocused(outlookMessage), Importance = !outlookMessage.Importance.HasValue ? MailImportance.Normal : (MailImportance)outlookMessage.Importance.Value, @@ -155,7 +155,7 @@ public static class OutlookIntegratorExtensions CcRecipients = ccAddresses, BccRecipients = bccAddresses, From = fromAddress, - InternetMessageId = mime.MessageId, + InternetMessageId = MailHeaderExtensions.ToHeaderMessageId(mime.MessageId), ReplyTo = replyToAddresses, }; diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index df5b27c8..69415630 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -2030,13 +2030,13 @@ public class GmailSynchronizer : WinoSynchronizer 0) - copy.References = string.Join(";", mime.References); + copy.References = MailHeaderExtensions.JoinStoredReferences(mime.References); if (!copy.HasAttachments && mime.Attachments.Any()) copy.HasAttachments = true; diff --git a/Wino.Services/Extensions/MailkitClientExtensions.cs b/Wino.Services/Extensions/MailkitClientExtensions.cs index 2219f3ee..d42dedd1 100644 --- a/Wino.Services/Extensions/MailkitClientExtensions.cs +++ b/Wino.Services/Extensions/MailkitClientExtensions.cs @@ -5,6 +5,7 @@ using MimeKit; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Extensions; namespace Wino.Services.Extensions; @@ -64,22 +65,16 @@ public static class MailkitClientExtensions } public static string GetMessageId(this MimeMessage mimeMessage) - => mimeMessage.MessageId; + => MailHeaderExtensions.NormalizeMessageId(mimeMessage.Headers[HeaderId.MessageId]); public static string GetReferences(this MessageIdList messageIdList) - => string.Join(";", messageIdList); + => MailHeaderExtensions.JoinStoredReferences(messageIdList); public static string GetInReplyTo(this MimeMessage mimeMessage) { if (mimeMessage.Headers.Contains(HeaderId.InReplyTo)) { - // Normalize if <> brackets are there. - var inReplyTo = mimeMessage.Headers[HeaderId.InReplyTo]; - - if (inReplyTo.StartsWith("<") && inReplyTo.EndsWith(">")) - return inReplyTo.Substring(1, inReplyTo.Length - 2); - - return inReplyTo; + return MailHeaderExtensions.NormalizeMessageId(mimeMessage.Headers[HeaderId.InReplyTo]); } return string.Empty; @@ -109,11 +104,11 @@ public static class MailkitClientExtensions ?? envelope?.Date?.UtcDateTime ?? DateTime.UtcNow; - var messageId = mime?.GetMessageId() ?? envelope?.MessageId ?? string.Empty; + var messageId = MailHeaderExtensions.NormalizeMessageId(mime?.GetMessageId() ?? envelope?.MessageId); var fromName = mime != null ? GetActualSenderName(mime) : GetEnvelopeSenderName(envelope); 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 inReplyTo = MailHeaderExtensions.NormalizeMessageId(mime != null ? mime.GetInReplyTo() : envelope?.InReplyTo); var threadId = ResolveThreadId(messageSummary, messageId, references, inReplyTo); var hasAttachments = mime != null ? mime.Attachments.Any() : false; var itemType = mime != null ? GetMailItemTypeFromMime(mime) : MailItemType.Mail; diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index d35d8fe1..304cc500 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -14,6 +14,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Misc; using Wino.Core.Domain.Models.MailItem; using Wino.Messaging.UI; using Wino.Services.Extensions; @@ -82,41 +83,20 @@ public class MailService : BaseDatabaseService, IMailService DraftId = $"{Constants.LocalDraftStartPrefix}{Guid.NewGuid()}", AssignedFolder = draftFolder, AssignedAccount = composerAccount, - FileId = Guid.NewGuid() + FileId = Guid.NewGuid(), + MessageId = GetNormalizedMimeMessageId(createdDraftMimeMessage), + InReplyTo = GetNormalizedMimeInReplyTo(createdDraftMimeMessage), + References = GetNormalizedMimeReferences(createdDraftMimeMessage) }; - // If replying, add In-Reply-To, ThreadId and References per RFC 5322. - // References must include all previous References + the Message-ID of the message being replied to. if (draftCreationOptions.ReferencedMessage != null) { - var refMime = draftCreationOptions.ReferencedMessage.MimeMessage; - var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy; - - string referenceMessageId = refMime?.MessageId; - string referenceInReplyTo = refMime?.InReplyTo; - IEnumerable referenceChain = refMime?.References ?? []; - - // Fallback to MailCopy metadata if MIME lacks threading headers. - if (string.IsNullOrWhiteSpace(referenceMessageId) && referenceMailCopy != null) - { - 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; + copy.ThreadId = MailHeaderExtensions.SplitMessageIds(copy.References).FirstOrDefault() ?? copy.InReplyTo; } await Connection.InsertAsync(copy, typeof(MailCopy)); @@ -997,6 +977,7 @@ public class MailService : BaseDatabaseService, IMailService { Headers = { { Constants.WinoLocalDraftHeader, Guid.NewGuid().ToString() } }, }; + EnsureOutgoingMessageId(message); var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(account.Id) ?? throw new MissingAliasException(); @@ -1086,6 +1067,7 @@ public class MailService : BaseDatabaseService, IMailService { var reason = draftCreationOptions.Reason; var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage; + var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy; ownAddresses ??= new HashSet(StringComparer.OrdinalIgnoreCase); var gap = CreateHtmlGap(); @@ -1167,39 +1149,23 @@ public class MailService : BaseDatabaseService, IMailService } } - // 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 = MailHeaderExtensions.StripAngleBrackets(referenceMessage.MessageId); + var referenceMessageId = MailHeaderExtensions.NormalizeMessageId(referenceMessage.Headers[HeaderId.MessageId]); + if (string.IsNullOrEmpty(referenceMessageId)) + referenceMessageId = MailHeaderExtensions.NormalizeMessageId(referenceMailCopy?.MessageId); - var refs = BuildReferencesChain( - referenceMessage.References, - referenceMessage.InReplyTo, - referenceMessage.MessageId); + if (!string.IsNullOrEmpty(referenceMessageId)) + { + message.InReplyTo = referenceMessageId; + + var existingReferences = referenceMessage.References?.Select(MailHeaderExtensions.NormalizeMessageId).ToList() ?? []; + if (existingReferences.Count == 0) + existingReferences = MailHeaderExtensions.SplitMessageIds(referenceMailCopy?.References).ToList(); + + var refs = MailHeaderExtensions.BuildReferencesChain(existingReferences, referenceMessageId); foreach (var referenceId in refs) message.References.Add(referenceId); } - else - { - // WARNING: Reference message has no Message-ID! - // This will break threading. Try to use the MessageId from MailCopy if available. - var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy; - if (referenceMailCopy != null && !string.IsNullOrEmpty(referenceMailCopy.MessageId)) - { - message.InReplyTo = MailHeaderExtensions.StripAngleBrackets(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); @@ -1209,8 +1175,8 @@ public class MailService : BaseDatabaseService, IMailService 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 if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) && !referenceSubject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase)) + message.Subject = $"Re: {referenceSubject}"; else message.Subject = referenceSubject; @@ -1394,45 +1360,49 @@ public class MailService : BaseDatabaseService, IMailService return ownAddresses; } - private static IEnumerable SplitStoredReferences(string references) + private static void EnsureOutgoingMessageId(MimeMessage message) { - if (string.IsNullOrWhiteSpace(references)) - return []; + if (message == null) + return; - return references - .Split(new[] { ';', ',', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .Select(r => r.Trim()); + var messageId = MailHeaderExtensions.NormalizeMessageId(MessageIdGenerator.Generate()); + + if (string.IsNullOrEmpty(messageId)) + return; + + var headerValue = MailHeaderExtensions.ToHeaderMessageId(messageId); + + if (message.Headers.Contains(HeaderId.MessageId)) + message.Headers.Remove(HeaderId.MessageId); + + message.Headers.Add(HeaderId.MessageId, headerValue); + message.MessageId = messageId; } - private static List BuildReferencesChain(IEnumerable existingReferences, string parentInReplyTo, string parentMessageId) + private static string GetNormalizedMimeMessageId(MimeMessage message) + => MailHeaderExtensions.NormalizeMessageId(message?.Headers[HeaderId.MessageId]); + + private static string GetNormalizedMimeInReplyTo(MimeMessage message) { - var results = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + if (message == null) + return string.Empty; - void AddReference(string value) - { - var normalized = MailHeaderExtensions.StripAngleBrackets(value)?.Trim(); - if (string.IsNullOrWhiteSpace(normalized)) - return; - if (!seen.Add(normalized)) - return; + var inReplyTo = string.IsNullOrWhiteSpace(message.InReplyTo) + ? message.Headers[HeaderId.InReplyTo] + : message.InReplyTo; - results.Add(normalized); - } + return MailHeaderExtensions.NormalizeMessageId(inReplyTo); + } - if (existingReferences != null) - { - foreach (var reference in existingReferences) - AddReference(reference); - } + private static string GetNormalizedMimeReferences(MimeMessage message) + { + if (message == null) + return string.Empty; - // RFC 5322 fallback: if References is absent, include parent In-Reply-To first when available. - if (results.Count == 0) - AddReference(parentInReplyTo); + if (message.References?.Count > 0) + return MailHeaderExtensions.JoinStoredReferences(message.References); - AddReference(parentMessageId); - - return results; + return MailHeaderExtensions.NormalizeReferences(message.Headers[HeaderId.References]); } public async Task> GetRecentMailIdsForFolderAsync(Guid folderId, int count)