Improving thread mapping for all synchronizers.
This commit is contained in:
@@ -131,6 +131,12 @@ public interface IMailService
|
||||
/// <returns>Draft MailCopy and Draft MimeMessage as base64.</returns>
|
||||
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>
|
||||
/// Returns ids
|
||||
/// </summary>
|
||||
|
||||
@@ -438,7 +438,9 @@ public static class OutlookIntegratorExtensions
|
||||
private static List<InternetMessageHeader> 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;
|
||||
|
||||
@@ -1183,6 +1183,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
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);
|
||||
|
||||
var mimeString = singleDraftRequest.Request.Mime.ToString();
|
||||
|
||||
@@ -1812,6 +1812,10 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
{
|
||||
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)
|
||||
{
|
||||
var formattedErrorString = string.Join("\n",
|
||||
|
||||
@@ -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);
|
||||
|
||||
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<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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
+186
-46
@@ -91,22 +91,33 @@ public class MailService : BaseDatabaseService, IMailService
|
||||
if (draftCreationOptions.ReferencedMessage != null)
|
||||
{
|
||||
var refMime = draftCreationOptions.ReferencedMessage.MimeMessage;
|
||||
var refs = new List<string>();
|
||||
var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
|
||||
|
||||
if (refMime.References != null)
|
||||
refs.AddRange(refMime.References);
|
||||
string referenceMessageId = refMime?.MessageId;
|
||||
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;
|
||||
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<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)
|
||||
{
|
||||
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<string> ownAddresses)
|
||||
{
|
||||
var reason = draftCreationOptions.Reason;
|
||||
var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage;
|
||||
ownAddresses ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var gap = CreateHtmlGap();
|
||||
builder.HtmlBody = gap + CreateHtmlForReferencingMessage(referenceMessage);
|
||||
@@ -1012,44 +1049,88 @@ 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<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var ccRecipients = new HashSet<string>(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 selfRecipients = message.To.Mailboxes
|
||||
.Where(m => ownAddresses.Contains(m.Address ?? string.Empty))
|
||||
.ToList();
|
||||
|
||||
foreach (var self in selfRecipients)
|
||||
{
|
||||
var self = message.To.FirstOrDefault(x => x is MailboxAddress mailboxAddress && mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase));
|
||||
if (self != null)
|
||||
message.To.Remove(self);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = 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());
|
||||
}
|
||||
}
|
||||
|
||||
message.References.Add(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);
|
||||
}
|
||||
|
||||
// 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<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)
|
||||
{
|
||||
var recentMails = await Connection.Table<MailCopy>()
|
||||
|
||||
Reference in New Issue
Block a user