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> /// <returns>Draft MailCopy and Draft MimeMessage as base64.</returns>
Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions); 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> /// <summary>
/// Returns ids /// Returns ids
/// </summary> /// </summary>
@@ -438,7 +438,9 @@ public static class OutlookIntegratorExtensions
private static List<InternetMessageHeader> GetHeaderList(this MimeMessage mime) private static List<InternetMessageHeader> GetHeaderList(this MimeMessage mime)
{ {
// Graph API only allows max of 5 headers. // 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; const int headerLimit = 5;
string[] headersToIgnore = ["Date", "To", "Cc", "Bcc", "MIME-Version", "From", "Subject", "Message-Id"]; string[] headersToIgnore = ["Date", "To", "Cc", "Bcc", "MIME-Version", "From", "Subject", "Message-Id"];
@@ -461,18 +463,12 @@ public static class OutlookIntegratorExtensions
if (winoDraftHeader != null) if (winoDraftHeader != null)
AddHeader(winoDraftHeader.Field, winoDraftHeader.Value); 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). // Fill remaining slots with custom headers only (avoid Graph restrictions).
foreach (var header in mime.Headers) foreach (var header in mime.Headers)
{ {
if (headers.Count >= headerLimit) break; if (headers.Count >= headerLimit) break;
if (header.Field == Domain.Constants.WinoLocalDraftHeader) continue; if (header.Field == Domain.Constants.WinoLocalDraftHeader) continue;
if (headersToIgnore.Contains(header.Field)) 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. // Only include custom headers beyond the core threading ones.
if (!header.Field.StartsWith("X-", StringComparison.OrdinalIgnoreCase)) continue; if (!header.Field.StartsWith("X-", StringComparison.OrdinalIgnoreCase)) continue;
@@ -1183,6 +1183,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
message.ThreadId = singleDraftRequest.Item.ThreadId; 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); singleDraftRequest.Request.Mime.Prepare(EncodingConstraint.None);
var mimeString = singleDraftRequest.Request.Mime.ToString(); var mimeString = singleDraftRequest.Request.Mime.ToString();
@@ -1812,6 +1812,10 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
{ {
await HandleFailedResponseAsync(bundle, response, errors); 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) private void ThrowBatchExecutionException(List<string> errors)
{ {
var formattedErrorString = string.Join("\n", var formattedErrorString = string.Join("\n",
+35 -6
View File
@@ -327,10 +327,7 @@ public partial class ComposePageViewModel : MailBaseViewModel,
using MemoryStream memoryStream = new(); using MemoryStream memoryStream = new();
CurrentMimeMessage.WriteTo(FormatOptions.Default, memoryStream); CurrentMimeMessage.WriteTo(FormatOptions.Default, memoryStream);
byte[] buffer = memoryStream.GetBuffer(); var base64EncodedMessage = Convert.ToBase64String(memoryStream.ToArray());
int count = (int)memoryStream.Length;
var base64EncodedMessage = Convert.ToBase64String(buffer);
var draftSendPreparationRequest = new SendDraftPreparationRequest(CurrentMailDraftItem.MailCopy, var draftSendPreparationRequest = new SendDraftPreparationRequest(CurrentMailDraftItem.MailCopy,
SelectedAlias, SelectedAlias,
sentFolder, sentFolder,
@@ -364,11 +361,13 @@ public partial class ComposePageViewModel : MailBaseViewModel,
await UpdateMimeChangesAsync().ConfigureAwait(false); await UpdateMimeChangesAsync().ConfigureAwait(false);
var localDraftCopy = CurrentMailDraftItem.MailCopy; var localDraftCopy = CurrentMailDraftItem.MailCopy;
var (retryReason, referenceMailCopy) = await ResolveRetryDraftContextAsync().ConfigureAwait(false);
var draftPreparationRequest = new DraftPreparationRequest( var draftPreparationRequest = new DraftPreparationRequest(
localDraftCopy.AssignedAccount ?? ComposingAccount, localDraftCopy.AssignedAccount ?? ComposingAccount,
localDraftCopy, localDraftCopy,
CurrentMimeMessage.GetBase64MimeMessage(), CurrentMimeMessage.GetBase64MimeMessage(),
DraftCreationReason.Empty); retryReason,
referenceMailCopy);
await _worker.ExecuteAsync(draftPreparationRequest).ConfigureAwait(false); await _worker.ExecuteAsync(draftPreparationRequest).ConfigureAwait(false);
} }
@@ -384,7 +383,11 @@ public partial class ComposePageViewModel : MailBaseViewModel,
}); });
await UpdatePendingOperationStateAsync().ConfigureAwait(false); 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)); 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) 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. // 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 fromAddress = mime != null ? GetActualSenderAddress(mime) : GetEnvelopeSenderAddress(envelope);
var references = mime?.References?.GetReferences() ?? messageSummary.References?.GetReferences(); var references = mime?.References?.GetReferences() ?? messageSummary.References?.GetReferences();
var inReplyTo = mime != null ? mime.GetInReplyTo() : envelope?.InReplyTo ?? string.Empty; 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 hasAttachments = mime != null ? mime.Attachments.Any() : false;
var itemType = mime != null ? GetMailItemTypeFromMime(mime) : MailItemType.Mail; var itemType = mime != null ? GetMailItemTypeFromMime(mime) : MailItemType.Mail;
@@ -121,7 +122,7 @@ public static class MailkitClientExtensions
{ {
Id = messageUid, Id = messageUid,
CreationDate = creationDate, CreationDate = creationDate,
ThreadId = messageSummary.GetThreadId(), ThreadId = threadId,
MessageId = messageId, MessageId = messageId,
Subject = subject, Subject = subject,
IsRead = messageSummary.Flags.GetIsRead(), IsRead = messageSummary.Flags.GetIsRead(),
@@ -141,6 +142,48 @@ public static class MailkitClientExtensions
return copy; 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) private static string GetPreviewText(IMessageSummary messageSummary, string subjectFallback)
{ {
if (!string.IsNullOrWhiteSpace(messageSummary.PreviewText)) if (!string.IsNullOrWhiteSpace(messageSummary.PreviewText))
+186 -46
View File
@@ -91,22 +91,33 @@ public class MailService : BaseDatabaseService, IMailService
if (draftCreationOptions.ReferencedMessage != null) if (draftCreationOptions.ReferencedMessage != null)
{ {
var refMime = draftCreationOptions.ReferencedMessage.MimeMessage; var refMime = draftCreationOptions.ReferencedMessage.MimeMessage;
var refs = new List<string>(); var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
if (refMime.References != null) string referenceMessageId = refMime?.MessageId;
refs.AddRange(refMime.References); 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; referenceMessageId = referenceMailCopy.MessageId;
refs.Add(refMime.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) if (refs.Count > 0)
copy.References = string.Join(";", refs); copy.References = string.Join(";", refs);
if (!string.IsNullOrEmpty(draftCreationOptions.ReferencedMessage.MailCopy?.ThreadId)) if (!string.IsNullOrEmpty(draftCreationOptions.ReferencedMessage.MailCopy?.ThreadId))
copy.ThreadId = 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)); await Connection.InsertAsync(copy, typeof(MailCopy));
@@ -154,6 +165,26 @@ public class MailService : BaseDatabaseService, IMailService
return unreadMails; 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) private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options)
{ {
var sql = new StringBuilder(); var sql = new StringBuilder();
@@ -925,11 +956,12 @@ public class MailService : BaseDatabaseService, IMailService
var builder = new BodyBuilder(); var builder = new BodyBuilder();
var signature = await GetSignature(account, draftCreationOptions.Reason); var signature = await GetSignature(account, draftCreationOptions.Reason);
var ownAddresses = await GetOwnAddressesAsync(account).ConfigureAwait(false);
_ = draftCreationOptions.Reason switch _ = draftCreationOptions.Reason switch
{ {
DraftCreationReason.Empty => CreateEmptyDraft(builder, message, draftCreationOptions, signature), DraftCreationReason.Empty => CreateEmptyDraft(builder, message, draftCreationOptions, signature),
_ => CreateReferencedDraft(builder, message, draftCreationOptions, account, signature), _ => CreateReferencedDraft(builder, message, draftCreationOptions, signature, ownAddresses),
}; };
// TODO: Migration // TODO: Migration
@@ -996,10 +1028,15 @@ public class MailService : BaseDatabaseService, IMailService
return message; 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 reason = draftCreationOptions.Reason;
var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage; var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage;
ownAddresses ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var gap = CreateHtmlGap(); var gap = CreateHtmlGap();
builder.HtmlBody = gap + CreateHtmlForReferencingMessage(referenceMessage); builder.HtmlBody = gap + CreateHtmlForReferencingMessage(referenceMessage);
@@ -1012,27 +1049,72 @@ public class MailService : BaseDatabaseService, IMailService
// Manage "To" // Manage "To"
if (reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) if (reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll)
{ {
// Reply to the sender of the message var toRecipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (referenceMessage.ReplyTo.Count > 0) var ccRecipients = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
message.To.AddRange(referenceMessage.ReplyTo);
else if (referenceMessage.From.Count > 0) void AddToRecipient(MailboxAddress mailbox, bool allowSelf)
message.To.AddRange(referenceMessage.From); {
else if (referenceMessage.Sender != null) var address = mailbox?.Address?.Trim();
message.To.Add(referenceMessage.Sender); 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) if (reason == DraftCreationReason.ReplyAll)
{ {
// Include all of the other original recipients // 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))); foreach (var mailbox in referenceMessage.To.Mailboxes)
message.Cc.AddRange(referenceMessage.Cc.Where(x => x is MailboxAddress mailboxAddress && !mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase))); 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. // 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)); var selfRecipients = message.To.Mailboxes
if (self != null) .Where(m => ownAddresses.Contains(m.Address ?? string.Empty))
.ToList();
foreach (var self in selfRecipients)
{
message.To.Remove(self); message.To.Remove(self);
}
} }
// Manage "ThreadId-ConversationId" // 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 // They must reference the original message's Message-ID from the MIME headers
if (!string.IsNullOrEmpty(referenceMessage.MessageId)) if (!string.IsNullOrEmpty(referenceMessage.MessageId))
{ {
message.InReplyTo = referenceMessage.MessageId; message.InReplyTo = MailHeaderExtensions.StripAngleBrackets(referenceMessage.MessageId);
// Add all previous References first var refs = BuildReferencesChain(
if (referenceMessage.References != null && referenceMessage.References.Count > 0) referenceMessage.References,
{ referenceMessage.InReplyTo,
message.References.AddRange(referenceMessage.References); referenceMessage.MessageId);
}
// Then add the message we're replying to foreach (var referenceId in refs)
message.References.Add(referenceMessage.MessageId); message.References.Add(referenceId);
} }
else else
{ {
@@ -1058,32 +1139,30 @@ public class MailService : BaseDatabaseService, IMailService
var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy; var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
if (referenceMailCopy != null && !string.IsNullOrEmpty(referenceMailCopy.MessageId)) if (referenceMailCopy != null && !string.IsNullOrEmpty(referenceMailCopy.MessageId))
{ {
message.InReplyTo = referenceMailCopy.MessageId; message.InReplyTo = MailHeaderExtensions.StripAngleBrackets(referenceMailCopy.MessageId);
if (!string.IsNullOrEmpty(referenceMailCopy.References)) var refs = BuildReferencesChain(
{ SplitStoredReferences(referenceMailCopy.References),
// Parse the References string (supports both ";" and "," separators for backward compatibility) referenceMailCopy.InReplyTo,
var references = referenceMailCopy.References.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries); referenceMailCopy.MessageId);
foreach (var reference in references)
{
message.References.Add(reference.Trim());
}
}
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 // Manage Subject
if (reason == DraftCreationReason.Forward && !referenceMessage.Subject.StartsWith("FW: ", StringComparison.OrdinalIgnoreCase)) var referenceSubject = referenceMessage?.Subject ?? string.Empty;
message.Subject = $"FW: {referenceMessage.Subject}"; if (reason == DraftCreationReason.Forward && !referenceSubject.StartsWith("FW: ", StringComparison.OrdinalIgnoreCase))
else if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) && !referenceMessage.Subject.StartsWith("RE: ", StringComparison.OrdinalIgnoreCase)) message.Subject = $"FW: {referenceSubject}";
message.Subject = $"RE: {referenceMessage.Subject}"; else if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) && !referenceSubject.StartsWith("RE: ", StringComparison.OrdinalIgnoreCase))
else if (referenceMessage != null) message.Subject = $"RE: {referenceSubject}";
message.Subject = referenceMessage.Subject; else
message.Subject = referenceSubject;
// Only include attachments if forwarding. // Only include attachments if forwarding.
if (reason == DraftCreationReason.Forward && (referenceMessage?.Attachments?.Any() ?? false)) if (reason == DraftCreationReason.Forward && (referenceMessage?.Attachments?.Any() ?? false))
@@ -1245,6 +1324,67 @@ public class MailService : BaseDatabaseService, IMailService
return new GmailArchiveComparisonResult(addedMails, removedMails); 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) public async Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
{ {
var recentMails = await Connection.Table<MailCopy>() var recentMails = await Connection.Table<MailCopy>()