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)