Fixing issues with replies.
This commit is contained in:
@@ -1,10 +1,26 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Extensions;
|
namespace Wino.Core.Domain.Extensions;
|
||||||
|
|
||||||
public static class MailHeaderExtensions
|
public static class MailHeaderExtensions
|
||||||
{
|
{
|
||||||
|
public static string NormalizeMessageId(string value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normalized = StripAngleBrackets(value)?.Trim();
|
||||||
|
return string.IsNullOrWhiteSpace(normalized) ? string.Empty : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToHeaderMessageId(string value)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeMessageId(value);
|
||||||
|
return string.IsNullOrEmpty(normalized) ? string.Empty : $"<{normalized}>";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Strips angle brackets from a Message-ID or In-Reply-To value.
|
/// Strips angle brackets from a Message-ID or In-Reply-To value.
|
||||||
/// RFC 5322 Message-IDs are formatted as <id@domain>, but MimeKit
|
/// RFC 5322 Message-IDs are formatted as <id@domain>, but MimeKit
|
||||||
@@ -29,14 +45,53 @@ public static class MailHeaderExtensions
|
|||||||
/// like "<id1@domain> <id2@domain>". This converts them to "id1@domain;id2@domain".
|
/// like "<id1@domain> <id2@domain>". This converts them to "id1@domain;id2@domain".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string NormalizeReferences(string rawReferences)
|
public static string NormalizeReferences(string rawReferences)
|
||||||
|
=> JoinStoredReferences(SplitMessageIds(rawReferences));
|
||||||
|
|
||||||
|
public static IEnumerable<string> SplitMessageIds(string values)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(rawReferences)) return rawReferences;
|
if (string.IsNullOrWhiteSpace(values))
|
||||||
|
return [];
|
||||||
|
|
||||||
var ids = rawReferences
|
return values
|
||||||
.Split(new[] { ' ', '\t', '\r', '\n', ';', ',' }, StringSplitOptions.RemoveEmptyEntries)
|
.Split(new[] { ' ', '\t', '\r', '\n', ';', ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(StripAngleBrackets)
|
.Select(NormalizeMessageId)
|
||||||
.Where(id => !string.IsNullOrEmpty(id));
|
.Where(id => !string.IsNullOrEmpty(id));
|
||||||
|
}
|
||||||
|
|
||||||
return string.Join(";", ids);
|
public static string JoinStoredReferences(IEnumerable<string> values)
|
||||||
|
=> string.Join(";", NormalizeDistinctMessageIds(values));
|
||||||
|
|
||||||
|
public static string BuildReferencesHeaderValue(IEnumerable<string> values)
|
||||||
|
=> string.Join(" ", NormalizeDistinctMessageIds(values).Select(ToHeaderMessageId));
|
||||||
|
|
||||||
|
public static List<string> BuildReferencesChain(IEnumerable<string> existingReferences, string parentMessageId)
|
||||||
|
{
|
||||||
|
var results = NormalizeDistinctMessageIds(existingReferences).ToList();
|
||||||
|
var normalizedParentMessageId = NormalizeMessageId(parentMessageId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(normalizedParentMessageId) &&
|
||||||
|
!results.Contains(normalizedParentMessageId, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
results.Add(normalizedParentMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> NormalizeDistinctMessageIds(IEnumerable<string> values)
|
||||||
|
{
|
||||||
|
if (values == null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeMessageId(value);
|
||||||
|
if (string.IsNullOrEmpty(normalized) || !seen.Add(normalized))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
yield return normalized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Misc;
|
||||||
|
|
||||||
|
public static class MessageIdGenerator
|
||||||
|
{
|
||||||
|
private const string Domain = "wino-mail.app";
|
||||||
|
|
||||||
|
public static string Generate()
|
||||||
|
{
|
||||||
|
return $"<{Guid.NewGuid()}@{Domain}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Wino.Core.Domain.Extensions;
|
||||||
|
using Wino.Core.Domain.Misc;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Tests.Services;
|
||||||
|
|
||||||
|
public class MailHeaderExtensionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MessageIdGenerator_Generate_ReturnsGuidAtWinoMailDomain()
|
||||||
|
{
|
||||||
|
var generated = MessageIdGenerator.Generate();
|
||||||
|
|
||||||
|
generated.Should().MatchRegex("^<[0-9a-fA-F-]{36}@wino-mail\\.app>$");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildReferencesChain_DeduplicatesAndAppendsParentMessageId()
|
||||||
|
{
|
||||||
|
var chain = MailHeaderExtensions.BuildReferencesChain(
|
||||||
|
["<root@domain.com>", "middle@domain.com", "<middle@domain.com>"],
|
||||||
|
"<parent@domain.com>");
|
||||||
|
|
||||||
|
chain.Should().Equal("root@domain.com", "middle@domain.com", "parent@domain.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using MimeKit;
|
||||||
|
using Moq;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Extensions;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
|
using Wino.Core.Tests.Helpers;
|
||||||
|
using Wino.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Tests.Services;
|
||||||
|
|
||||||
|
public class MailThreadingTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private InMemoryDatabaseService _databaseService = null!;
|
||||||
|
private MailService _mailService = null!;
|
||||||
|
private MailAccount _account = null!;
|
||||||
|
private MailItemFolder _draftFolder = null!;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_databaseService = new InMemoryDatabaseService();
|
||||||
|
await _databaseService.InitializeAsync();
|
||||||
|
|
||||||
|
_account = new MailAccount
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Threading Test Account",
|
||||||
|
Address = "me@test.local",
|
||||||
|
SenderName = "Test User",
|
||||||
|
ProviderType = MailProviderType.IMAP4
|
||||||
|
};
|
||||||
|
|
||||||
|
_draftFolder = new MailItemFolder
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
MailAccountId = _account.Id,
|
||||||
|
FolderName = "Drafts",
|
||||||
|
SpecialFolderType = SpecialFolderType.Draft,
|
||||||
|
IsSystemFolder = true,
|
||||||
|
IsSynchronizationEnabled = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var preferences = new MailAccountPreferences
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = _account.Id,
|
||||||
|
IsNotificationsEnabled = true,
|
||||||
|
IsSignatureEnabled = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var alias = new MailAccountAlias
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = _account.Id,
|
||||||
|
AliasAddress = _account.Address,
|
||||||
|
ReplyToAddress = _account.Address,
|
||||||
|
IsPrimary = true,
|
||||||
|
IsRootAlias = true,
|
||||||
|
IsVerified = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await _databaseService.Connection.InsertAsync(_account, typeof(MailAccount));
|
||||||
|
await _databaseService.Connection.InsertAsync(_draftFolder, typeof(MailItemFolder));
|
||||||
|
await _databaseService.Connection.InsertAsync(preferences, typeof(MailAccountPreferences));
|
||||||
|
await _databaseService.Connection.InsertAsync(alias, typeof(MailAccountAlias));
|
||||||
|
|
||||||
|
_mailService = BuildMailService(_databaseService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync() => await _databaseService.DisposeAsync();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateDraftAsync_EmptyDraft_AssignsGeneratedMessageId()
|
||||||
|
{
|
||||||
|
var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(
|
||||||
|
_account.Id,
|
||||||
|
new DraftCreationOptions { Reason = DraftCreationReason.Empty });
|
||||||
|
|
||||||
|
var mimeMessage = draftBase64MimeMessage.GetMimeMessageFromBase64();
|
||||||
|
|
||||||
|
draftMailCopy.MessageId.Should().MatchRegex("^[0-9a-fA-F-]{36}@wino-mail\\.app$");
|
||||||
|
mimeMessage.MessageId.Should().Be(draftMailCopy.MessageId);
|
||||||
|
mimeMessage.Headers[HeaderId.MessageId].Should().Be(MailHeaderExtensions.ToHeaderMessageId(draftMailCopy.MessageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateDraftAsync_Reply_SetsInReplyToReferencesAndReplySubject()
|
||||||
|
{
|
||||||
|
const string parentMessageId = "original@domain.com";
|
||||||
|
|
||||||
|
var referencedMimeMessage = CreateReferencedMimeMessage("From outlook", parentMessageId);
|
||||||
|
var referencedMailCopy = new MailCopy
|
||||||
|
{
|
||||||
|
UniqueId = Guid.NewGuid(),
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ThreadId = "provider-thread-id",
|
||||||
|
MessageId = parentMessageId
|
||||||
|
};
|
||||||
|
|
||||||
|
var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(
|
||||||
|
_account.Id,
|
||||||
|
new DraftCreationOptions
|
||||||
|
{
|
||||||
|
Reason = DraftCreationReason.Reply,
|
||||||
|
ReferencedMessage = new ReferencedMessage
|
||||||
|
{
|
||||||
|
MimeMessage = referencedMimeMessage,
|
||||||
|
MailCopy = referencedMailCopy
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var mimeMessage = draftBase64MimeMessage.GetMimeMessageFromBase64();
|
||||||
|
|
||||||
|
draftMailCopy.InReplyTo.Should().Be(parentMessageId);
|
||||||
|
draftMailCopy.References.Should().Be(parentMessageId);
|
||||||
|
draftMailCopy.Subject.Should().Be("Re: From outlook");
|
||||||
|
draftMailCopy.ThreadId.Should().Be(referencedMailCopy.ThreadId);
|
||||||
|
|
||||||
|
mimeMessage.InReplyTo.Should().Be(parentMessageId);
|
||||||
|
MailHeaderExtensions.NormalizeReferences(mimeMessage.Headers[HeaderId.References]).Should().Be(parentMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateDraftAsync_Reply_AppendsReferencesChainOnce()
|
||||||
|
{
|
||||||
|
const string rootMessageId = "root@domain.com";
|
||||||
|
const string middleMessageId = "middle@domain.com";
|
||||||
|
const string parentMessageId = "parent@domain.com";
|
||||||
|
|
||||||
|
var referencedMimeMessage = CreateReferencedMimeMessage("Re: Existing subject", parentMessageId);
|
||||||
|
referencedMimeMessage.References.Add(rootMessageId);
|
||||||
|
referencedMimeMessage.References.Add(middleMessageId);
|
||||||
|
|
||||||
|
var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(
|
||||||
|
_account.Id,
|
||||||
|
new DraftCreationOptions
|
||||||
|
{
|
||||||
|
Reason = DraftCreationReason.Reply,
|
||||||
|
ReferencedMessage = new ReferencedMessage
|
||||||
|
{
|
||||||
|
MimeMessage = referencedMimeMessage,
|
||||||
|
MailCopy = new MailCopy { UniqueId = Guid.NewGuid(), Id = Guid.NewGuid().ToString(), MessageId = parentMessageId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var mimeMessage = draftBase64MimeMessage.GetMimeMessageFromBase64();
|
||||||
|
|
||||||
|
draftMailCopy.References.Should().Be($"{rootMessageId};{middleMessageId};{parentMessageId}");
|
||||||
|
draftMailCopy.Subject.Should().Be("Re: Existing subject");
|
||||||
|
MailHeaderExtensions.NormalizeReferences(mimeMessage.Headers[HeaderId.References])
|
||||||
|
.Should().Be($"{rootMessageId};{middleMessageId};{parentMessageId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateDraftAsync_Reply_FallsBackToReferencedMailCopyThreadingMetadata()
|
||||||
|
{
|
||||||
|
const string rootMessageId = "root@domain.com";
|
||||||
|
const string parentMessageId = "copy-parent@domain.com";
|
||||||
|
|
||||||
|
var referencedMimeMessage = CreateReferencedMimeMessage("Fallback subject");
|
||||||
|
referencedMimeMessage.Headers.Remove(HeaderId.MessageId);
|
||||||
|
|
||||||
|
var referencedMailCopy = new MailCopy
|
||||||
|
{
|
||||||
|
UniqueId = Guid.NewGuid(),
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
MessageId = parentMessageId,
|
||||||
|
References = rootMessageId
|
||||||
|
};
|
||||||
|
|
||||||
|
var (draftMailCopy, _) = await _mailService.CreateDraftAsync(
|
||||||
|
_account.Id,
|
||||||
|
new DraftCreationOptions
|
||||||
|
{
|
||||||
|
Reason = DraftCreationReason.Reply,
|
||||||
|
ReferencedMessage = new ReferencedMessage
|
||||||
|
{
|
||||||
|
MimeMessage = referencedMimeMessage,
|
||||||
|
MailCopy = referencedMailCopy
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
draftMailCopy.InReplyTo.Should().Be(parentMessageId);
|
||||||
|
draftMailCopy.References.Should().Be($"{rootMessageId};{parentMessageId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MimeMessage CreateReferencedMimeMessage(string subject, string? messageId = null)
|
||||||
|
{
|
||||||
|
var message = new MimeMessage();
|
||||||
|
message.From.Add(new MailboxAddress("Sender", "sender@example.com"));
|
||||||
|
message.To.Add(new MailboxAddress("Recipient", "recipient@example.com"));
|
||||||
|
message.Subject = subject;
|
||||||
|
message.Body = new TextPart("plain") { Text = "Body" };
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(messageId))
|
||||||
|
message.MessageId = messageId;
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MailService BuildMailService(InMemoryDatabaseService db)
|
||||||
|
{
|
||||||
|
var signatureService = new Mock<ISignatureService>();
|
||||||
|
var authProvider = new Mock<IAuthenticationProvider>();
|
||||||
|
var mimeFileService = new Mock<IMimeFileService>();
|
||||||
|
mimeFileService
|
||||||
|
.Setup(x => x.SaveMimeMessageAsync(It.IsAny<Guid>(), It.IsAny<MimeMessage>(), It.IsAny<Guid>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
mimeFileService
|
||||||
|
.Setup(x => x.CreateHTMLPreviewVisitor(It.IsAny<MimeMessage>(), It.IsAny<string>()))
|
||||||
|
.Returns<MimeMessage, string>((_, _) => new HtmlPreviewVisitor(string.Empty));
|
||||||
|
|
||||||
|
var preferencesService = new Mock<IPreferencesService>();
|
||||||
|
preferencesService.SetupProperty(x => x.ComposerFont, "Calibri");
|
||||||
|
preferencesService.SetupProperty(x => x.ComposerFontSize, 12);
|
||||||
|
|
||||||
|
var contactPictureFileService = new Mock<IContactPictureFileService>();
|
||||||
|
|
||||||
|
var accountService = new AccountService(
|
||||||
|
db,
|
||||||
|
signatureService.Object,
|
||||||
|
authProvider.Object,
|
||||||
|
mimeFileService.Object,
|
||||||
|
preferencesService.Object,
|
||||||
|
contactPictureFileService.Object);
|
||||||
|
|
||||||
|
var folderService = new FolderService(db, accountService);
|
||||||
|
var contactService = new ContactService(db);
|
||||||
|
|
||||||
|
return new MailService(
|
||||||
|
db,
|
||||||
|
folderService,
|
||||||
|
contactService,
|
||||||
|
accountService,
|
||||||
|
signatureService.Object,
|
||||||
|
mimeFileService.Object,
|
||||||
|
preferencesService.Object);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ public static class OutlookIntegratorExtensions
|
|||||||
|
|
||||||
var mailCopy = new MailCopy()
|
var mailCopy = new MailCopy()
|
||||||
{
|
{
|
||||||
MessageId = outlookMessage.InternetMessageId,
|
MessageId = MailHeaderExtensions.NormalizeMessageId(outlookMessage.InternetMessageId),
|
||||||
IsFlagged = GetIsFlagged(outlookMessage),
|
IsFlagged = GetIsFlagged(outlookMessage),
|
||||||
IsFocused = GetIsFocused(outlookMessage),
|
IsFocused = GetIsFocused(outlookMessage),
|
||||||
Importance = !outlookMessage.Importance.HasValue ? MailImportance.Normal : (MailImportance)outlookMessage.Importance.Value,
|
Importance = !outlookMessage.Importance.HasValue ? MailImportance.Normal : (MailImportance)outlookMessage.Importance.Value,
|
||||||
@@ -155,7 +155,7 @@ public static class OutlookIntegratorExtensions
|
|||||||
CcRecipients = ccAddresses,
|
CcRecipients = ccAddresses,
|
||||||
BccRecipients = bccAddresses,
|
BccRecipients = bccAddresses,
|
||||||
From = fromAddress,
|
From = fromAddress,
|
||||||
InternetMessageId = mime.MessageId,
|
InternetMessageId = MailHeaderExtensions.ToHeaderMessageId(mime.MessageId),
|
||||||
ReplyTo = replyToAddresses,
|
ReplyTo = replyToAddresses,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2030,13 +2030,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(copy.MessageId))
|
if (string.IsNullOrEmpty(copy.MessageId))
|
||||||
copy.MessageId = mime.MessageId;
|
copy.MessageId = MailHeaderExtensions.NormalizeMessageId(mime.Headers[HeaderId.MessageId]);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(copy.InReplyTo))
|
if (string.IsNullOrEmpty(copy.InReplyTo))
|
||||||
copy.InReplyTo = mime.InReplyTo;
|
copy.InReplyTo = MailHeaderExtensions.NormalizeMessageId(mime.InReplyTo);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(copy.References) && mime.References?.Count > 0)
|
if (string.IsNullOrEmpty(copy.References) && mime.References?.Count > 0)
|
||||||
copy.References = string.Join(";", mime.References);
|
copy.References = MailHeaderExtensions.JoinStoredReferences(mime.References);
|
||||||
|
|
||||||
if (!copy.HasAttachments && mime.Attachments.Any())
|
if (!copy.HasAttachments && mime.Attachments.Any())
|
||||||
copy.HasAttachments = true;
|
copy.HasAttachments = true;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using MimeKit;
|
|||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Extensions;
|
||||||
|
|
||||||
namespace Wino.Services.Extensions;
|
namespace Wino.Services.Extensions;
|
||||||
|
|
||||||
@@ -64,22 +65,16 @@ public static class MailkitClientExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static string GetMessageId(this MimeMessage mimeMessage)
|
public static string GetMessageId(this MimeMessage mimeMessage)
|
||||||
=> mimeMessage.MessageId;
|
=> MailHeaderExtensions.NormalizeMessageId(mimeMessage.Headers[HeaderId.MessageId]);
|
||||||
|
|
||||||
public static string GetReferences(this MessageIdList messageIdList)
|
public static string GetReferences(this MessageIdList messageIdList)
|
||||||
=> string.Join(";", messageIdList);
|
=> MailHeaderExtensions.JoinStoredReferences(messageIdList);
|
||||||
|
|
||||||
public static string GetInReplyTo(this MimeMessage mimeMessage)
|
public static string GetInReplyTo(this MimeMessage mimeMessage)
|
||||||
{
|
{
|
||||||
if (mimeMessage.Headers.Contains(HeaderId.InReplyTo))
|
if (mimeMessage.Headers.Contains(HeaderId.InReplyTo))
|
||||||
{
|
{
|
||||||
// Normalize if <> brackets are there.
|
return MailHeaderExtensions.NormalizeMessageId(mimeMessage.Headers[HeaderId.InReplyTo]);
|
||||||
var inReplyTo = mimeMessage.Headers[HeaderId.InReplyTo];
|
|
||||||
|
|
||||||
if (inReplyTo.StartsWith("<") && inReplyTo.EndsWith(">"))
|
|
||||||
return inReplyTo.Substring(1, inReplyTo.Length - 2);
|
|
||||||
|
|
||||||
return inReplyTo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
@@ -109,11 +104,11 @@ public static class MailkitClientExtensions
|
|||||||
?? envelope?.Date?.UtcDateTime
|
?? envelope?.Date?.UtcDateTime
|
||||||
?? DateTime.UtcNow;
|
?? DateTime.UtcNow;
|
||||||
|
|
||||||
var messageId = mime?.GetMessageId() ?? envelope?.MessageId ?? string.Empty;
|
var messageId = MailHeaderExtensions.NormalizeMessageId(mime?.GetMessageId() ?? envelope?.MessageId);
|
||||||
var fromName = mime != null ? GetActualSenderName(mime) : GetEnvelopeSenderName(envelope);
|
var fromName = mime != null ? GetActualSenderName(mime) : GetEnvelopeSenderName(envelope);
|
||||||
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 = MailHeaderExtensions.NormalizeMessageId(mime != null ? mime.GetInReplyTo() : envelope?.InReplyTo);
|
||||||
var threadId = ResolveThreadId(messageSummary, messageId, references, inReplyTo);
|
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;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using Wino.Core.Domain.Enums;
|
|||||||
using Wino.Core.Domain.Exceptions;
|
using Wino.Core.Domain.Exceptions;
|
||||||
using Wino.Core.Domain.Extensions;
|
using Wino.Core.Domain.Extensions;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Misc;
|
||||||
using Wino.Core.Domain.Models.MailItem;
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
using Wino.Messaging.UI;
|
using Wino.Messaging.UI;
|
||||||
using Wino.Services.Extensions;
|
using Wino.Services.Extensions;
|
||||||
@@ -82,41 +83,20 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
DraftId = $"{Constants.LocalDraftStartPrefix}{Guid.NewGuid()}",
|
DraftId = $"{Constants.LocalDraftStartPrefix}{Guid.NewGuid()}",
|
||||||
AssignedFolder = draftFolder,
|
AssignedFolder = draftFolder,
|
||||||
AssignedAccount = composerAccount,
|
AssignedAccount = composerAccount,
|
||||||
FileId = Guid.NewGuid()
|
FileId = Guid.NewGuid(),
|
||||||
|
MessageId = GetNormalizedMimeMessageId(createdDraftMimeMessage),
|
||||||
|
InReplyTo = GetNormalizedMimeInReplyTo(createdDraftMimeMessage),
|
||||||
|
References = GetNormalizedMimeReferences(createdDraftMimeMessage)
|
||||||
};
|
};
|
||||||
|
|
||||||
// If replying, add In-Reply-To, ThreadId and References per RFC 5322.
|
|
||||||
// References must include all previous References + the Message-ID of the message being replied to.
|
|
||||||
if (draftCreationOptions.ReferencedMessage != null)
|
if (draftCreationOptions.ReferencedMessage != null)
|
||||||
{
|
{
|
||||||
var refMime = draftCreationOptions.ReferencedMessage.MimeMessage;
|
|
||||||
var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
|
|
||||||
|
|
||||||
string referenceMessageId = refMime?.MessageId;
|
|
||||||
string referenceInReplyTo = refMime?.InReplyTo;
|
|
||||||
IEnumerable<string> referenceChain = refMime?.References ?? [];
|
|
||||||
|
|
||||||
// Fallback to MailCopy metadata if MIME lacks threading headers.
|
|
||||||
if (string.IsNullOrWhiteSpace(referenceMessageId) && referenceMailCopy != null)
|
|
||||||
{
|
|
||||||
referenceMessageId = referenceMailCopy.MessageId;
|
|
||||||
referenceInReplyTo = referenceMailCopy.InReplyTo;
|
|
||||||
referenceChain = SplitStoredReferences(referenceMailCopy.References);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(referenceMessageId))
|
|
||||||
copy.InReplyTo = MailHeaderExtensions.StripAngleBrackets(referenceMessageId);
|
|
||||||
|
|
||||||
var refs = BuildReferencesChain(referenceChain, referenceInReplyTo, referenceMessageId);
|
|
||||||
if (refs.Count > 0)
|
|
||||||
copy.References = string.Join(";", refs);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(draftCreationOptions.ReferencedMessage.MailCopy?.ThreadId))
|
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.
|
// Fallback local threading when provider/native thread id is unavailable.
|
||||||
if (string.IsNullOrWhiteSpace(copy.ThreadId))
|
if (string.IsNullOrWhiteSpace(copy.ThreadId))
|
||||||
copy.ThreadId = refs.FirstOrDefault() ?? copy.InReplyTo;
|
copy.ThreadId = MailHeaderExtensions.SplitMessageIds(copy.References).FirstOrDefault() ?? copy.InReplyTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Connection.InsertAsync(copy, typeof(MailCopy));
|
await Connection.InsertAsync(copy, typeof(MailCopy));
|
||||||
@@ -997,6 +977,7 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
{
|
{
|
||||||
Headers = { { Constants.WinoLocalDraftHeader, Guid.NewGuid().ToString() } },
|
Headers = { { Constants.WinoLocalDraftHeader, Guid.NewGuid().ToString() } },
|
||||||
};
|
};
|
||||||
|
EnsureOutgoingMessageId(message);
|
||||||
|
|
||||||
var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(account.Id) ?? throw new MissingAliasException();
|
var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(account.Id) ?? throw new MissingAliasException();
|
||||||
|
|
||||||
@@ -1086,6 +1067,7 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
{
|
{
|
||||||
var reason = draftCreationOptions.Reason;
|
var reason = draftCreationOptions.Reason;
|
||||||
var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage;
|
var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage;
|
||||||
|
var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
|
||||||
ownAddresses ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
ownAddresses ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var gap = CreateHtmlGap();
|
var gap = CreateHtmlGap();
|
||||||
@@ -1167,39 +1149,23 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage "ThreadId-ConversationId"
|
var referenceMessageId = MailHeaderExtensions.NormalizeMessageId(referenceMessage.Headers[HeaderId.MessageId]);
|
||||||
// CRITICAL: In-Reply-To and References headers are essential for threading
|
if (string.IsNullOrEmpty(referenceMessageId))
|
||||||
// They must reference the original message's Message-ID from the MIME headers
|
referenceMessageId = MailHeaderExtensions.NormalizeMessageId(referenceMailCopy?.MessageId);
|
||||||
if (!string.IsNullOrEmpty(referenceMessage.MessageId))
|
|
||||||
{
|
|
||||||
message.InReplyTo = MailHeaderExtensions.StripAngleBrackets(referenceMessage.MessageId);
|
|
||||||
|
|
||||||
var refs = BuildReferencesChain(
|
if (!string.IsNullOrEmpty(referenceMessageId))
|
||||||
referenceMessage.References,
|
{
|
||||||
referenceMessage.InReplyTo,
|
message.InReplyTo = referenceMessageId;
|
||||||
referenceMessage.MessageId);
|
|
||||||
|
var existingReferences = referenceMessage.References?.Select(MailHeaderExtensions.NormalizeMessageId).ToList() ?? [];
|
||||||
|
if (existingReferences.Count == 0)
|
||||||
|
existingReferences = MailHeaderExtensions.SplitMessageIds(referenceMailCopy?.References).ToList();
|
||||||
|
|
||||||
|
var refs = MailHeaderExtensions.BuildReferencesChain(existingReferences, referenceMessageId);
|
||||||
|
|
||||||
foreach (var referenceId in refs)
|
foreach (var referenceId in refs)
|
||||||
message.References.Add(referenceId);
|
message.References.Add(referenceId);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
// WARNING: Reference message has no Message-ID!
|
|
||||||
// This will break threading. Try to use the MessageId from MailCopy if available.
|
|
||||||
var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
|
|
||||||
if (referenceMailCopy != null && !string.IsNullOrEmpty(referenceMailCopy.MessageId))
|
|
||||||
{
|
|
||||||
message.InReplyTo = MailHeaderExtensions.StripAngleBrackets(referenceMailCopy.MessageId);
|
|
||||||
|
|
||||||
var refs = BuildReferencesChain(
|
|
||||||
SplitStoredReferences(referenceMailCopy.References),
|
|
||||||
referenceMailCopy.InReplyTo,
|
|
||||||
referenceMailCopy.MessageId);
|
|
||||||
|
|
||||||
foreach (var referenceId in refs)
|
|
||||||
message.References.Add(referenceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(referenceMessage.Subject))
|
if (!string.IsNullOrEmpty(referenceMessage.Subject))
|
||||||
message.Headers.Add("Thread-Topic", referenceMessage.Subject);
|
message.Headers.Add("Thread-Topic", referenceMessage.Subject);
|
||||||
@@ -1209,8 +1175,8 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
var referenceSubject = referenceMessage?.Subject ?? string.Empty;
|
var referenceSubject = referenceMessage?.Subject ?? string.Empty;
|
||||||
if (reason == DraftCreationReason.Forward && !referenceSubject.StartsWith("FW: ", StringComparison.OrdinalIgnoreCase))
|
if (reason == DraftCreationReason.Forward && !referenceSubject.StartsWith("FW: ", StringComparison.OrdinalIgnoreCase))
|
||||||
message.Subject = $"FW: {referenceSubject}";
|
message.Subject = $"FW: {referenceSubject}";
|
||||||
else if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) && !referenceSubject.StartsWith("RE: ", StringComparison.OrdinalIgnoreCase))
|
else if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) && !referenceSubject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase))
|
||||||
message.Subject = $"RE: {referenceSubject}";
|
message.Subject = $"Re: {referenceSubject}";
|
||||||
else
|
else
|
||||||
message.Subject = referenceSubject;
|
message.Subject = referenceSubject;
|
||||||
|
|
||||||
@@ -1394,45 +1360,49 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
return ownAddresses;
|
return ownAddresses;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> SplitStoredReferences(string references)
|
private static void EnsureOutgoingMessageId(MimeMessage message)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(references))
|
if (message == null)
|
||||||
return [];
|
return;
|
||||||
|
|
||||||
return references
|
var messageId = MailHeaderExtensions.NormalizeMessageId(MessageIdGenerator.Generate());
|
||||||
.Split(new[] { ';', ',', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
|
||||||
.Select(r => r.Trim());
|
if (string.IsNullOrEmpty(messageId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var headerValue = MailHeaderExtensions.ToHeaderMessageId(messageId);
|
||||||
|
|
||||||
|
if (message.Headers.Contains(HeaderId.MessageId))
|
||||||
|
message.Headers.Remove(HeaderId.MessageId);
|
||||||
|
|
||||||
|
message.Headers.Add(HeaderId.MessageId, headerValue);
|
||||||
|
message.MessageId = messageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string> BuildReferencesChain(IEnumerable<string> existingReferences, string parentInReplyTo, string parentMessageId)
|
private static string GetNormalizedMimeMessageId(MimeMessage message)
|
||||||
|
=> MailHeaderExtensions.NormalizeMessageId(message?.Headers[HeaderId.MessageId]);
|
||||||
|
|
||||||
|
private static string GetNormalizedMimeInReplyTo(MimeMessage message)
|
||||||
{
|
{
|
||||||
var results = new List<string>();
|
if (message == null)
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
return string.Empty;
|
||||||
|
|
||||||
void AddReference(string value)
|
var inReplyTo = string.IsNullOrWhiteSpace(message.InReplyTo)
|
||||||
{
|
? message.Headers[HeaderId.InReplyTo]
|
||||||
var normalized = MailHeaderExtensions.StripAngleBrackets(value)?.Trim();
|
: message.InReplyTo;
|
||||||
if (string.IsNullOrWhiteSpace(normalized))
|
|
||||||
return;
|
|
||||||
if (!seen.Add(normalized))
|
|
||||||
return;
|
|
||||||
|
|
||||||
results.Add(normalized);
|
return MailHeaderExtensions.NormalizeMessageId(inReplyTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingReferences != null)
|
private static string GetNormalizedMimeReferences(MimeMessage message)
|
||||||
{
|
{
|
||||||
foreach (var reference in existingReferences)
|
if (message == null)
|
||||||
AddReference(reference);
|
return string.Empty;
|
||||||
}
|
|
||||||
|
|
||||||
// RFC 5322 fallback: if References is absent, include parent In-Reply-To first when available.
|
if (message.References?.Count > 0)
|
||||||
if (results.Count == 0)
|
return MailHeaderExtensions.JoinStoredReferences(message.References);
|
||||||
AddReference(parentInReplyTo);
|
|
||||||
|
|
||||||
AddReference(parentMessageId);
|
return MailHeaderExtensions.NormalizeReferences(message.Headers[HeaderId.References]);
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
|
public async Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
|
||||||
|
|||||||
Reference in New Issue
Block a user