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()