diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs
index ced42c2e..f5f4b30d 100644
--- a/Wino.Core.Domain/Interfaces/IMailService.cs
+++ b/Wino.Core.Domain/Interfaces/IMailService.cs
@@ -131,6 +131,12 @@ public interface IMailService
/// Draft MailCopy and Draft MimeMessage as base64.
Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions);
+ ///
+ /// Finds a mail copy in the given account by RFC Message-Id.
+ /// Returns null when no local match exists.
+ ///
+ Task GetMailCopyByMessageIdAsync(Guid accountId, string messageId);
+
///
/// Returns ids
///
diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs
index 140bdd79..1e5d0e78 100644
--- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs
+++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs
@@ -438,7 +438,9 @@ public static class OutlookIntegratorExtensions
private static List 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;
diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs
index 2ee9eb3e..8ba7e1f8 100644
--- a/Wino.Core/Synchronizers/GmailSynchronizer.cs
+++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs
@@ -1183,6 +1183,9 @@ public class GmailSynchronizer : WinoSynchronizer 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();
+ if (string.IsNullOrWhiteSpace(createdDraftId))
+ return;
+
+ var createdConversationId = json?["conversationId"]?.GetValue();
+ 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 errors)
{
var formattedErrorString = string.Join("\n",
diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs
index a8defbdd..04f66c93 100644
--- a/Wino.Mail.ViewModels/ComposePageViewModel.cs
+++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs
@@ -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);
- NotifyComposeActionStateChanged();
+
+ 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 GetAddressInformationAsync(string tokenText, ObservableCollection collection)
{
// Get model from the service. This will make sure the name is properly included if there is any record.
diff --git a/Wino.Services/Extensions/MailkitClientExtensions.cs b/Wino.Services/Extensions/MailkitClientExtensions.cs
index 311c5e77..2219f3ee 100644
--- a/Wino.Services/Extensions/MailkitClientExtensions.cs
+++ b/Wino.Services/Extensions/MailkitClientExtensions.cs
@@ -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))
diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs
index a8632edb..987f0d4d 100644
--- a/Wino.Services/MailService.cs
+++ b/Wino.Services/MailService.cs
@@ -91,22 +91,33 @@ public class MailService : BaseDatabaseService, IMailService
if (draftCreationOptions.ReferencedMessage != null)
{
var refMime = draftCreationOptions.ReferencedMessage.MimeMessage;
- var refs = new List();
+ var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
- if (refMime.References != null)
- refs.AddRange(refMime.References);
+ string referenceMessageId = refMime?.MessageId;
+ string referenceInReplyTo = refMime?.InReplyTo;
+ IEnumerable 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 GetMailCopyByMessageIdAsync(Guid accountId, string messageId)
+ {
+ var normalizedMessageId = MailHeaderExtensions.StripAngleBrackets(messageId)?.Trim();
+ if (string.IsNullOrWhiteSpace(normalizedMessageId))
+ return null;
+
+ var mailCopy = await Connection.FindWithQueryAsync(
+ "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 ownAddresses)
{
var reason = draftCreationOptions.Reason;
var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage;
+ ownAddresses ??= new HashSet(StringComparer.OrdinalIgnoreCase);
var gap = CreateHtmlGap();
builder.HtmlBody = gap + CreateHtmlForReferencingMessage(referenceMessage);
@@ -1012,27 +1049,72 @@ 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(StringComparer.OrdinalIgnoreCase);
+ var ccRecipients = new HashSet(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 self = message.To.FirstOrDefault(x => x is MailboxAddress mailboxAddress && mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase));
- if (self != null)
+ var selfRecipients = message.To.Mailboxes
+ .Where(m => ownAddresses.Contains(m.Address ?? string.Empty))
+ .ToList();
+
+ foreach (var self in selfRecipients)
+ {
message.To.Remove(self);
+ }
}
// Manage "ThreadId-ConversationId"
@@ -1040,16 +1122,15 @@ public class MailService : BaseDatabaseService, IMailService
// 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());
- }
- }
+ var refs = BuildReferencesChain(
+ SplitStoredReferences(referenceMailCopy.References),
+ referenceMailCopy.InReplyTo,
+ referenceMailCopy.MessageId);
- message.References.Add(referenceMailCopy.MessageId);
+ foreach (var referenceId in refs)
+ message.References.Add(referenceId);
}
}
- message.Headers.Add("Thread-Topic", referenceMessage.Subject);
+ 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> GetOwnAddressesAsync(MailAccount account)
+ {
+ var ownAddresses = new HashSet(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 SplitStoredReferences(string references)
+ {
+ if (string.IsNullOrWhiteSpace(references))
+ return [];
+
+ return references
+ .Split(new[] { ';', ',', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(r => r.Trim());
+ }
+
+ private static List BuildReferencesChain(IEnumerable existingReferences, string parentInReplyTo, string parentMessageId)
+ {
+ var results = new List();
+ var seen = new HashSet(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> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
{
var recentMails = await Connection.Table()