diff --git a/Wino.Core.Domain/Constants.cs b/Wino.Core.Domain/Constants.cs index cded77eb..d6614c84 100644 --- a/Wino.Core.Domain/Constants.cs +++ b/Wino.Core.Domain/Constants.cs @@ -6,6 +6,8 @@ public static class Constants /// MIME header that exists in all the drafts created from Wino. /// public const string WinoLocalDraftHeader = "X-Wino-Draft-Id"; + public const string DispositionNotificationToHeader = "Disposition-Notification-To"; + public const string OriginalMessageIdHeader = "Original-Message-ID"; public const string LocalDraftStartPrefix = "localDraft_"; public const string CalendarEventRecurrenceRuleSeperator = "___"; diff --git a/Wino.Core.Domain/Entities/Mail/MailCopy.cs b/Wino.Core.Domain/Entities/Mail/MailCopy.cs index ebd71c61..845b843d 100644 --- a/Wino.Core.Domain/Entities/Mail/MailCopy.cs +++ b/Wino.Core.Domain/Entities/Mail/MailCopy.cs @@ -155,6 +155,18 @@ public class MailCopy [Ignore] public AccountContact SenderContact { get; set; } + [Ignore] + public bool IsReadReceiptRequested { get; set; } + + [Ignore] + public SentMailReceiptStatus ReadReceiptStatus { get; set; } + + [Ignore] + public DateTime? ReadReceiptAcknowledgedAtUtc { get; set; } + + [Ignore] + public Guid? ReadReceiptMessageUniqueId { get; set; } + public IEnumerable GetContainingIds() => [UniqueId]; public override string ToString() => $"{Subject} <-> {Id}"; } diff --git a/Wino.Core.Domain/Entities/Mail/SentMailReceiptState.cs b/Wino.Core.Domain/Entities/Mail/SentMailReceiptState.cs new file mode 100644 index 00000000..3462cdc1 --- /dev/null +++ b/Wino.Core.Domain/Entities/Mail/SentMailReceiptState.cs @@ -0,0 +1,25 @@ +using System; +using SQLite; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Entities.Mail; + +public class SentMailReceiptState +{ + [PrimaryKey] + public Guid MailUniqueId { get; set; } + + public Guid AccountId { get; set; } + + public string MessageId { get; set; } + + public bool IsReceiptRequested { get; set; } + + public DateTime RequestedAtUtc { get; set; } + + public SentMailReceiptStatus Status { get; set; } + + public DateTime? AcknowledgedAtUtc { get; set; } + + public Guid? ReceiptMessageUniqueId { get; set; } +} diff --git a/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs b/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs index f500aa5d..a6dea93f 100644 --- a/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs +++ b/Wino.Core.Domain/Enums/MailCopyChangeFlags.cs @@ -30,6 +30,7 @@ public enum MailCopyChangeFlags AssignedAccount = 1 << 21, SenderContact = 1 << 22, UniqueId = 1 << 23, + ReadReceiptState = 1 << 24, All = Id | FolderId | ThreadId | @@ -53,5 +54,6 @@ public enum MailCopyChangeFlags AssignedFolder | AssignedAccount | SenderContact | - UniqueId + UniqueId | + ReadReceiptState } diff --git a/Wino.Core.Domain/Enums/SentMailReceiptStatus.cs b/Wino.Core.Domain/Enums/SentMailReceiptStatus.cs new file mode 100644 index 00000000..c2937bfe --- /dev/null +++ b/Wino.Core.Domain/Enums/SentMailReceiptStatus.cs @@ -0,0 +1,9 @@ +namespace Wino.Core.Domain.Enums; + +public enum SentMailReceiptStatus +{ + None = 0, + Requested = 1, + Acknowledged = 2, + FailedToCorrelate = 3 +} diff --git a/Wino.Core.Domain/Extensions/ReadReceiptExtensions.cs b/Wino.Core.Domain/Extensions/ReadReceiptExtensions.cs new file mode 100644 index 00000000..5ba1d8e3 --- /dev/null +++ b/Wino.Core.Domain/Extensions/ReadReceiptExtensions.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using MimeKit; + +namespace Wino.Core.Domain.Extensions; + +public static class ReadReceiptExtensions +{ + public static bool HasReadReceiptRequest(this MimeMessage mimeMessage) + => mimeMessage?.Headers?.Contains(Constants.DispositionNotificationToHeader) == true + && !string.IsNullOrWhiteSpace(mimeMessage.Headers[Constants.DispositionNotificationToHeader]); + + public static void SetReadReceiptRequest(this MimeMessage mimeMessage, string address, bool isRequested) + { + if (mimeMessage == null) + return; + + mimeMessage.Headers.Remove(Constants.DispositionNotificationToHeader); + + if (isRequested && !string.IsNullOrWhiteSpace(address)) + { + mimeMessage.Headers.Add(Constants.DispositionNotificationToHeader, address.Trim()); + } + } + + public static bool LooksLikeReadReceipt(this MimeMessage mimeMessage) + { + if (mimeMessage?.Body == null) + return false; + + return mimeMessage.BodyParts.Any(IsReadReceiptEntity) || IsReadReceiptEntity(mimeMessage.Body); + } + + public static ReadReceiptParseResult ParseReadReceipt(this MimeMessage mimeMessage) + { + if (!mimeMessage.LooksLikeReadReceipt()) + return ReadReceiptParseResult.Empty; + + var entity = mimeMessage.BodyParts.FirstOrDefault(IsReadReceiptEntity) ?? mimeMessage.Body; + var lines = ReadEntityLines(entity); + + string originalMessageId = null; + + foreach (var line in lines) + { + if (line.StartsWith(Constants.OriginalMessageIdHeader + ":", StringComparison.OrdinalIgnoreCase)) + { + originalMessageId = line.Substring(line.IndexOf(':') + 1).Trim(); + break; + } + } + + var acknowledgedAtUtc = mimeMessage.Date != DateTimeOffset.MinValue + ? mimeMessage.Date.UtcDateTime + : (DateTime?)null; + + return new ReadReceiptParseResult( + true, + MailHeaderExtensions.NormalizeMessageId(originalMessageId), + acknowledgedAtUtc); + } + + private static bool IsReadReceiptEntity(MimeEntity entity) + { + if (entity?.ContentType == null) + return false; + + if (entity.ContentType.MimeType.Equals("message/disposition-notification", StringComparison.OrdinalIgnoreCase)) + return true; + + var reportType = entity.ContentType.Parameters["report-type"]; + return entity.ContentType.MimeType.Equals("multipart/report", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(reportType) + && reportType.Equals("disposition-notification", StringComparison.OrdinalIgnoreCase); + } + + private static IEnumerable ReadEntityLines(MimeEntity entity) + { + if (entity is TextPart textPart) + { + return SplitLines(textPart.Text); + } + + if (entity is MimePart mimePart) + { + using var memoryStream = new MemoryStream(); + mimePart.Content?.DecodeTo(memoryStream); + memoryStream.Position = 0; + using var reader = new StreamReader(memoryStream); + return SplitLines(reader.ReadToEnd()); + } + + using var serializedStream = new MemoryStream(); + entity.WriteTo(serializedStream); + serializedStream.Position = 0; + using var serializedReader = new StreamReader(serializedStream); + return SplitLines(serializedReader.ReadToEnd()); + } + + private static IEnumerable SplitLines(string content) + => string.IsNullOrWhiteSpace(content) + ? [] + : content.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries); +} + +public sealed record ReadReceiptParseResult(bool IsReadReceipt, string OriginalMessageId, DateTime? AcknowledgedAtUtc) +{ + public static ReadReceiptParseResult Empty { get; } = new(false, string.Empty, null); +} diff --git a/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs b/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs index 613f2be2..b8ec5e08 100644 --- a/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs +++ b/Wino.Core.Domain/Interfaces/IMailItemDisplayInformation.cs @@ -24,4 +24,7 @@ public interface IMailItemDisplayInformation : INotifyPropertyChanged bool ThumbnailUpdatedEvent { get; } bool IsThreadExpanded { get; } AccountContact SenderContact { get; } + bool HasReadReceiptTracking { get; } + bool IsReadReceiptAcknowledged { get; } + string ReadReceiptDisplayText { get; } } diff --git a/Wino.Core.Domain/Interfaces/ISentMailReceiptService.cs b/Wino.Core.Domain/Interfaces/ISentMailReceiptService.cs new file mode 100644 index 00000000..e611eecb --- /dev/null +++ b/Wino.Core.Domain/Interfaces/ISentMailReceiptService.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MimeKit; +using Wino.Core.Domain.Entities.Mail; + +namespace Wino.Core.Domain.Interfaces; + +public interface ISentMailReceiptService +{ + Task PopulateReceiptStateAsync(MailCopy mailCopy); + Task PopulateReceiptStatesAsync(IReadOnlyCollection mailCopies); + Task TrackSentMailAsync(MailCopy mailCopy, MimeMessage mimeMessage = null); + Task ProcessIncomingReceiptAsync(MailCopy receiptMail, MimeMessage mimeMessage); +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index d49740b2..6bfce498 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1102,6 +1102,7 @@ "Composer_CcBcc": "Cc & Bcc", "Composer_EnableSmimeSignature": "Enable/disable S/MIME signature", "Composer_EnableSmimeEncryption": "Enable/disable S/MIME encryption", + "Composer_RequestReadReceipt": "Request read receipt", "Composer_LocalDraftSyncInfo": "This draft is local only. Wino failed to send it to your mail server. Click to retry sending it to the server.", "Composer_CertificateExpires": "Expires on: ", "Composer_SmimeSignature": "S/MIME Signature", @@ -1168,6 +1169,8 @@ "Composer_AiTranslateSuccessTitle": "AI translation applied", "Composer_AiRewriteSuccessTitle": "AI rewrite applied", "Composer_AiErrorTitle": "AI action failed", + "MailReceiptStatus_Requested": "Receipt requested", + "MailReceiptStatus_Acknowledged": "Read receipt received", "Reader_AiAppliedMessage": "The AI result is now shown for this message. Reopen the message to view the original content again.", "SettingsAppPreferences_EmailSyncInterval_Title": "Email sync interval", "SettingsAppPreferences_EmailSyncInterval_Description": "Automatic email synchronization interval (minutes). This setting will be applied only after restarting Wino Mail.", diff --git a/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs index 2c027196..acc31100 100644 --- a/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs +++ b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs @@ -50,6 +50,7 @@ public class InMemoryDatabaseService : IDatabaseService await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); } diff --git a/Wino.Core.Tests/Services/MailFetchingTests.cs b/Wino.Core.Tests/Services/MailFetchingTests.cs index 4b3f9f68..66d8bf2e 100644 --- a/Wino.Core.Tests/Services/MailFetchingTests.cs +++ b/Wino.Core.Tests/Services/MailFetchingTests.cs @@ -370,6 +370,7 @@ public class MailFetchingTests : IAsyncLifetime var folderService = new FolderService(db, accountService); var contactService = new ContactService(db); + var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService); return new MailService( db, @@ -378,6 +379,7 @@ public class MailFetchingTests : IAsyncLifetime accountService, signatureService.Object, mimeFileService.Object, - preferencesService.Object); + preferencesService.Object, + sentMailReceiptService); } } diff --git a/Wino.Core.Tests/Services/MailThreadingTests.cs b/Wino.Core.Tests/Services/MailThreadingTests.cs index cec68654..eccdcd48 100644 --- a/Wino.Core.Tests/Services/MailThreadingTests.cs +++ b/Wino.Core.Tests/Services/MailThreadingTests.cs @@ -230,6 +230,7 @@ public class MailThreadingTests : IAsyncLifetime var folderService = new FolderService(db, accountService); var contactService = new ContactService(db); + var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService); return new MailService( db, @@ -238,6 +239,7 @@ public class MailThreadingTests : IAsyncLifetime accountService, signatureService.Object, mimeFileService.Object, - preferencesService.Object); + preferencesService.Object, + sentMailReceiptService); } } diff --git a/Wino.Core.Tests/Services/ReadReceiptTrackingTests.cs b/Wino.Core.Tests/Services/ReadReceiptTrackingTests.cs new file mode 100644 index 00000000..fdff0f16 --- /dev/null +++ b/Wino.Core.Tests/Services/ReadReceiptTrackingTests.cs @@ -0,0 +1,151 @@ +using FluentAssertions; +using MimeKit; +using Moq; +using System.IO; +using System.Text; +using Wino.Core.Domain; +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.Extensions; +using Wino.Core.Tests.Helpers; +using Wino.Services; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public sealed class ReadReceiptTrackingTests +{ + [Fact] + public void SetReadReceiptRequest_WhenEnabled_AddsDispositionNotificationHeader() + { + var mime = new MimeMessage(); + + mime.SetReadReceiptRequest("sender@example.com", true); + + mime.HasReadReceiptRequest().Should().BeTrue(); + mime.Headers[Constants.DispositionNotificationToHeader].Should().Be("sender@example.com"); + } + + [Fact] + public void SetReadReceiptRequest_WhenDisabled_RemovesDispositionNotificationHeader() + { + var mime = new MimeMessage(); + mime.Headers.Add(Constants.DispositionNotificationToHeader, "sender@example.com"); + + mime.SetReadReceiptRequest("sender@example.com", false); + + mime.HasReadReceiptRequest().Should().BeFalse(); + } + + [Fact] + public void ParseReadReceipt_ExtractsOriginalMessageIdAndAcknowledgedTime() + { + var mime = new MimeMessage + { + Date = new DateTimeOffset(2026, 04, 10, 12, 30, 0, TimeSpan.Zero), + Body = CreateDispositionNotificationBody("Final-Recipient: rfc822; recipient@example.com\r\nOriginal-Message-ID: \r\nDisposition: manual-action/MDN-sent-manually; displayed\r\n") + }; + + var result = mime.ParseReadReceipt(); + + result.IsReadReceipt.Should().BeTrue(); + result.OriginalMessageId.Should().Be("original@example.com"); + result.AcknowledgedAtUtc.Should().Be(new DateTime(2026, 04, 10, 12, 30, 0, DateTimeKind.Utc)); + } + + [Fact] + public void AsOutlookMessage_WhenMimeRequestsReadReceipt_SetsGraphFlag() + { + var mime = new MimeMessage + { + Subject = "Test receipt request", + Body = new TextPart("plain") { Text = "Hello" } + }; + mime.MessageId = "test@example.com"; + mime.From.Add(new MailboxAddress("Sender", "sender@example.com")); + mime.To.Add(new MailboxAddress("Recipient", "recipient@example.com")); + mime.SetReadReceiptRequest("sender@example.com", true); + + var message = mime.AsOutlookMessage(includeInternetHeaders: false); + + message.IsReadReceiptRequested.Should().BeTrue(); + } + + [Fact] + public async Task ProcessIncomingReceiptAsync_MatchesSentMailByMessageId_AndAcknowledgesState() + { + var db = new InMemoryDatabaseService(); + await db.InitializeAsync(); + + var account = new MailAccount + { + Id = Guid.NewGuid(), + Address = "sender@example.com", + SenderName = "Sender" + }; + + var sentFolder = new MailItemFolder + { + Id = Guid.NewGuid(), + MailAccountId = account.Id, + FolderName = "Sent", + SpecialFolderType = SpecialFolderType.Sent + }; + + var sentMail = new MailCopy + { + UniqueId = Guid.NewGuid(), + Id = "sent-1", + FolderId = sentFolder.Id, + MessageId = "original@example.com", + CreationDate = new DateTime(2026, 04, 10, 10, 0, 0, DateTimeKind.Utc) + }; + + await db.Connection.InsertAsync(account, typeof(MailAccount)); + await db.Connection.InsertAsync(sentFolder, typeof(MailItemFolder)); + await db.Connection.InsertAsync(sentMail, typeof(MailCopy)); + + var folderService = new Mock(MockBehavior.Strict); + folderService.Setup(x => x.GetFolderAsync(sentFolder.Id)).ReturnsAsync(sentFolder); + + var accountService = new Mock(MockBehavior.Strict); + accountService.Setup(x => x.GetAccountAsync(account.Id)).ReturnsAsync(account); + + var service = new SentMailReceiptService(db, folderService.Object, accountService.Object); + + var receiptMail = new MailCopy + { + UniqueId = Guid.NewGuid(), + AssignedAccount = account + }; + + var receiptMime = new MimeMessage + { + Date = new DateTimeOffset(2026, 04, 10, 13, 15, 0, TimeSpan.Zero), + Body = CreateDispositionNotificationBody("Original-Message-ID: \r\nDisposition: manual-action/MDN-sent-manually; displayed\r\n") + }; + + await service.ProcessIncomingReceiptAsync(receiptMail, receiptMime); + + var receiptState = await db.Connection.FindAsync(sentMail.UniqueId); + receiptState.Should().NotBeNull(); + receiptState!.Status.Should().Be(SentMailReceiptStatus.Acknowledged); + receiptState.ReceiptMessageUniqueId.Should().Be(receiptMail.UniqueId); + receiptState.MessageId.Should().Be("original@example.com"); + } + + private static Multipart CreateDispositionNotificationBody(string dispositionText) + { + var report = new Multipart("report"); + report.ContentType.Parameters.Add("report-type", "disposition-notification"); + report.Add(new TextPart("plain") { Text = "Read receipt" }); + report.Add(new MimePart("message", "disposition-notification") + { + Content = new MimeContent(new MemoryStream(Encoding.UTF8.GetBytes(dispositionText))) + }); + return report; + } +} diff --git a/Wino.Core.ViewModels/PersonalizationPageViewModel.cs b/Wino.Core.ViewModels/PersonalizationPageViewModel.cs index c247a9b8..6f8776ff 100644 --- a/Wino.Core.ViewModels/PersonalizationPageViewModel.cs +++ b/Wino.Core.ViewModels/PersonalizationPageViewModel.cs @@ -345,6 +345,9 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel public bool ThumbnailUpdatedEvent { get; } = false; public bool IsBusy { get; } = false; public bool IsThreadExpanded { get; } = false; + public bool HasReadReceiptTracking { get; } = false; + public bool IsReadReceiptAcknowledged { get; } = false; + public string ReadReceiptDisplayText { get; } = string.Empty; public AccountContact SenderContact { get; } = null; event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged { diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 8ec1407f..b3bdaeee 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -42,6 +42,12 @@ public static class OutlookIntegratorExtensions public static bool GetIsFlagged(this Message message) => message?.Flag?.FlagStatus != null && message.Flag.FlagStatus == FollowupFlagStatus.Flagged; + public static bool GetIsReadReceiptRequested(this Message message) + => message?.IsReadReceiptRequested.GetValueOrDefault() == true + || message?.InternetMessageHeaders?.Any(h => + string.Equals(h.Name, Domain.Constants.DispositionNotificationToHeader, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(h.Value)) == true; + public static MailCopy AsMailCopy(this Message outlookMessage) { bool isDraft = GetIsDraft(outlookMessage); @@ -53,6 +59,7 @@ public static class OutlookIntegratorExtensions IsFocused = GetIsFocused(outlookMessage), Importance = !outlookMessage.Importance.HasValue ? MailImportance.Normal : (MailImportance)outlookMessage.Importance.Value, IsRead = GetIsRead(outlookMessage), + IsReadReceiptRequested = GetIsReadReceiptRequested(outlookMessage), IsDraft = isDraft, CreationDate = outlookMessage.ReceivedDateTime.GetValueOrDefault().DateTime, HasAttachments = outlookMessage.HasAttachments.GetValueOrDefault(), @@ -156,6 +163,7 @@ public static class OutlookIntegratorExtensions BccRecipients = bccAddresses, From = fromAddress, InternetMessageId = MailHeaderExtensions.ToHeaderMessageId(mime.MessageId), + IsReadReceiptRequested = mime.HasReadReceiptRequest(), ReplyTo = replyToAddresses, }; diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index c3f66638..0a4c3781 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -2100,6 +2100,7 @@ public class GmailSynchronizer : WinoSynchronizer !string.IsNullOrEmpty(p.Filename)) ?? false, IsRead = !isUnread, + IsReadReceiptRequested = HasReadReceiptRequest(gmailMessage.Payload?.Headers), IsFlagged = isFlagged, IsFocused = isFocused, InReplyTo = MailHeaderExtensions.StripAngleBrackets(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("In-Reply-To", StringComparison.OrdinalIgnoreCase))?.Value), @@ -2148,6 +2149,9 @@ public class GmailSynchronizer : WinoSynchronizer headers) + => headers?.Any(h => h.Name.Equals(Domain.Constants.DispositionNotificationToHeader, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(h.Value)) == true; + + private static bool LooksLikeReadReceipt(IList headers) + { + var contentType = headers?.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))?.Value; + return !string.IsNullOrWhiteSpace(contentType) + && contentType.Contains("disposition-notification", StringComparison.OrdinalIgnoreCase); + } + private static IReadOnlyList ExtractContactsFromGmailMessage(Message message, MimeMessage mimeMessage) { var contacts = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -2348,7 +2363,9 @@ public class GmailSynchronizer : WinoSynchronizer + private static bool ShouldDownloadMimeForMessage(Message message, MailItemFolder folder, MailItemType itemType) + => folder.SpecialFolderType == SpecialFolderType.Draft + || itemType == MailItemType.CalendarInvitation + || LooksLikeReadReceipt(message); + + private static bool LooksLikeReadReceipt(Message message) + { + var contentType = message?.InternetMessageHeaders? + .FirstOrDefault(h => string.Equals(h.Name, "Content-Type", StringComparison.OrdinalIgnoreCase)) + ?.Value; + + return !string.IsNullOrWhiteSpace(contentType) + && contentType.Contains("disposition-notification", StringComparison.OrdinalIgnoreCase); + } + private async Task CreateMailCopyFromMessageAsync(Message message, MailItemFolder assignedFolder) { if (message == null) return null; diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs index 24a434f3..08c22afb 100644 --- a/Wino.Mail.ViewModels/ComposePageViewModel.cs +++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs @@ -123,6 +123,9 @@ public partial class ComposePageViewModel : MailBaseViewModel, [ObservableProperty] public partial bool IsSmimeEncryptionEnabled { get; set; } + [ObservableProperty] + public partial bool IsReadReceiptRequested { get; set; } + [ObservableProperty] public partial X509Certificate2 SelectedSigningCertificate { get; set; } @@ -421,6 +424,7 @@ public partial class ComposePageViewModel : MailBaseViewModel, SaveImportance(); SaveSubject(); SaveFromAddress(); + SaveReadReceiptRequest(); SaveReplyToAddress(); await SaveAttachmentsAsync(); @@ -754,6 +758,7 @@ public partial class ComposePageViewModel : MailBaseViewModel, IsCCBCCVisible = true; Subject = replyingMime.Subject; + IsReadReceiptRequested = replyingMime.HasReadReceiptRequest(); Messenger.Send(new CreateNewComposeMailRequested(renderModel)); }); @@ -816,6 +821,15 @@ public partial class ComposePageViewModel : MailBaseViewModel, } } + private void SaveReadReceiptRequest() + { + if (CurrentMimeMessage == null) + return; + + var receiptAddress = SelectedAlias?.AliasAddress ?? ComposingAccount?.Address ?? string.Empty; + CurrentMimeMessage.SetReadReceiptRequest(receiptAddress, IsReadReceiptRequested); + } + private void SaveAddressInfo(IEnumerable addresses, InternetAddressList list) { list.Clear(); diff --git a/Wino.Mail.ViewModels/Data/AccountContactViewModel.cs b/Wino.Mail.ViewModels/Data/AccountContactViewModel.cs index 7a9ae7f2..507d7408 100644 --- a/Wino.Mail.ViewModels/Data/AccountContactViewModel.cs +++ b/Wino.Mail.ViewModels/Data/AccountContactViewModel.cs @@ -68,6 +68,9 @@ public partial class AccountContactViewModel : ObservableObject, IMailItemDispla public DateTime CreationDate => default; public bool IsBusy => false; public bool IsThreadExpanded => false; + public bool HasReadReceiptTracking => false; + public bool IsReadReceiptAcknowledged => false; + public string ReadReceiptDisplayText => string.Empty; public AccountContact SenderContact => new() { Address = Address, diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs index 7eebd613..bff91871 100644 --- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; @@ -105,6 +106,17 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, set => SetProperty(MailCopy.IsDraft, value, MailCopy, (u, n) => u.IsDraft = n); } + public bool HasReadReceiptTracking => MailCopy.IsReadReceiptRequested; + + public bool IsReadReceiptAcknowledged => MailCopy.ReadReceiptStatus == SentMailReceiptStatus.Acknowledged; + + public string ReadReceiptDisplayText => MailCopy.ReadReceiptStatus switch + { + SentMailReceiptStatus.Acknowledged => Translator.MailReceiptStatus_Acknowledged, + SentMailReceiptStatus.Requested => Translator.MailReceiptStatus_Requested, + _ => string.Empty + }; + public string DraftId { get => MailCopy.DraftId; @@ -225,6 +237,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, nameof(IsFocused) => MailCopyChangeFlags.IsFocused, nameof(IsRead) => MailCopyChangeFlags.IsRead, nameof(IsDraft) => MailCopyChangeFlags.IsDraft, + nameof(HasReadReceiptTracking) or nameof(IsReadReceiptAcknowledged) or nameof(ReadReceiptDisplayText) => MailCopyChangeFlags.ReadReceiptState, nameof(DraftId) => MailCopyChangeFlags.DraftId, nameof(Id) => MailCopyChangeFlags.Id, nameof(Subject) => MailCopyChangeFlags.Subject, @@ -287,6 +300,10 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, changedFlags |= SetIfChanged(MailCopy.AssignedAccount, source.AssignedAccount, value => MailCopy.AssignedAccount = value, MailCopyChangeFlags.AssignedAccount); changedFlags |= SetIfChanged(MailCopy.AssignedFolder, source.AssignedFolder, value => MailCopy.AssignedFolder = value, MailCopyChangeFlags.AssignedFolder); changedFlags |= SetIfChanged(MailCopy.UniqueId, source.UniqueId, value => MailCopy.UniqueId = value, MailCopyChangeFlags.UniqueId); + changedFlags |= SetIfChanged(MailCopy.IsReadReceiptRequested, source.IsReadReceiptRequested, value => MailCopy.IsReadReceiptRequested = value, MailCopyChangeFlags.ReadReceiptState); + changedFlags |= SetIfChanged(MailCopy.ReadReceiptStatus, source.ReadReceiptStatus, value => MailCopy.ReadReceiptStatus = value, MailCopyChangeFlags.ReadReceiptState); + changedFlags |= SetIfChanged(MailCopy.ReadReceiptAcknowledgedAtUtc, source.ReadReceiptAcknowledgedAtUtc, value => MailCopy.ReadReceiptAcknowledgedAtUtc = value, MailCopyChangeFlags.ReadReceiptState); + changedFlags |= SetIfChanged(MailCopy.ReadReceiptMessageUniqueId, source.ReadReceiptMessageUniqueId, value => MailCopy.ReadReceiptMessageUniqueId = value, MailCopyChangeFlags.ReadReceiptState); } changedFlags |= changeHint; @@ -358,6 +375,13 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, if ((changedFlags & MailCopyChangeFlags.IsDraft) != 0) Queue(nameof(IsDraft)); + if ((changedFlags & MailCopyChangeFlags.ReadReceiptState) != 0) + { + Queue(nameof(HasReadReceiptTracking)); + Queue(nameof(IsReadReceiptAcknowledged)); + Queue(nameof(ReadReceiptDisplayText)); + } + if ((changedFlags & MailCopyChangeFlags.DraftId) != 0) Queue(nameof(DraftId)); diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs index bc82acf5..7508058a 100644 --- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs @@ -100,6 +100,12 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte /// public bool IsRead => ThreadEmails.All(e => e.IsRead); + public bool HasReadReceiptTracking => latestMailViewModel?.HasReadReceiptTracking ?? false; + + public bool IsReadReceiptAcknowledged => latestMailViewModel?.IsReadReceiptAcknowledged ?? false; + + public string ReadReceiptDisplayText => latestMailViewModel?.ReadReceiptDisplayText ?? string.Empty; + /// /// Gets whether any email in this thread is a draft /// @@ -177,6 +183,9 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte [NotifyPropertyChangedFor(nameof(IsFlagged))] [NotifyPropertyChangedFor(nameof(IsFocused))] [NotifyPropertyChangedFor(nameof(IsRead))] + [NotifyPropertyChangedFor(nameof(HasReadReceiptTracking))] + [NotifyPropertyChangedFor(nameof(IsReadReceiptAcknowledged))] + [NotifyPropertyChangedFor(nameof(ReadReceiptDisplayText))] [NotifyPropertyChangedFor(nameof(IsDraft))] [NotifyPropertyChangedFor(nameof(DraftId))] [NotifyPropertyChangedFor(nameof(Id))] @@ -451,6 +460,13 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte if ((changedFlags & MailCopyChangeFlags.IsRead) != 0 || changedFlags == MailCopyChangeFlags.All) Queue(nameof(IsRead)); + if ((changedFlags & MailCopyChangeFlags.ReadReceiptState) != 0 || changedFlags == MailCopyChangeFlags.All) + { + Queue(nameof(HasReadReceiptTracking)); + Queue(nameof(IsReadReceiptAcknowledged)); + Queue(nameof(ReadReceiptDisplayText)); + } + if ((changedFlags & MailCopyChangeFlags.IsDraft) != 0 || changedFlags == MailCopyChangeFlags.All) Queue(nameof(IsDraft)); diff --git a/Wino.Mail.ViewModels/MessageListPageViewModel.cs b/Wino.Mail.ViewModels/MessageListPageViewModel.cs index a08cf320..019f3d3c 100644 --- a/Wino.Mail.ViewModels/MessageListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MessageListPageViewModel.cs @@ -164,10 +164,13 @@ public partial class MessageListPageViewModel : MailBaseViewModel public Guid? ContactPictureFileId => null; public bool ThumbnailUpdatedEvent => false; public bool IsThreadExpanded => false; + public bool HasReadReceiptTracking => true; + public bool IsReadReceiptAcknowledged => false; + public string ReadReceiptDisplayText => Translator.MailReceiptStatus_Requested; public AccountContact SenderContact => new() { - Address = "ava@contoso.com", - Name = "Ava Brooks" + Address = "hi@bkaan.dev", + Name = "Burak Kaan Köse" }; } } diff --git a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml index b08d2f4b..adc2f9ef 100644 --- a/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml +++ b/Wino.Mail.WinUI/Controls/MailItemDisplayInformationControl.xaml @@ -234,6 +234,15 @@ Orientation="Horizontal" Spacing="2"> + + + + + + + + + diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index 18addf24..f2f902a0 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -68,6 +68,7 @@ public class DatabaseService : IDatabaseService Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), + Connection.CreateTableAsync(), Connection.CreateTableAsync()); await EnsureSchemaUpgradesAsync().ConfigureAwait(false); @@ -225,5 +226,8 @@ SET {nameof(KeyboardShortcut.Action)} = await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailInvitationCalendarMapping_InvitationUid ON MailInvitationCalendarMapping(InvitationUid)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailInvitationCalendarMapping_CalendarId ON MailInvitationCalendarMapping(CalendarId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailInvitationCalendarMapping_CalendarItemId ON MailInvitationCalendarMapping(CalendarItemId)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_SentMailReceiptState_MailUniqueId ON SentMailReceiptState(MailUniqueId)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_SentMailReceiptState_AccountId_MessageId ON SentMailReceiptState(AccountId, MessageId)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_SentMailReceiptState_Status ON SentMailReceiptState(Status)").ConfigureAwait(false); } } diff --git a/Wino.Services/Extensions/MailkitClientExtensions.cs b/Wino.Services/Extensions/MailkitClientExtensions.cs index d42dedd1..686638c4 100644 --- a/Wino.Services/Extensions/MailkitClientExtensions.cs +++ b/Wino.Services/Extensions/MailkitClientExtensions.cs @@ -121,6 +121,9 @@ public static class MailkitClientExtensions MessageId = messageId, Subject = subject, IsRead = messageSummary.Flags.GetIsRead(), + IsReadReceiptRequested = mime?.HasReadReceiptRequest() + ?? (messageSummary.Headers?.Contains(Constants.DispositionNotificationToHeader) == true + && !string.IsNullOrWhiteSpace(messageSummary.Headers[Constants.DispositionNotificationToHeader])), IsFlagged = messageSummary.Flags.GetIsFlagged(), PreviewText = previewText, FromAddress = fromAddress, diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index 69a15890..a5bd21f5 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -31,6 +31,7 @@ public class MailService : BaseDatabaseService, IMailService private readonly ISignatureService _signatureService; private readonly IMimeFileService _mimeFileService; private readonly IPreferencesService _preferencesService; + private readonly ISentMailReceiptService _sentMailReceiptService; private readonly ILogger _logger = Log.ForContext(); @@ -40,7 +41,8 @@ public class MailService : BaseDatabaseService, IMailService IAccountService accountService, ISignatureService signatureService, IMimeFileService mimeFileService, - IPreferencesService preferencesService) : base(databaseService) + IPreferencesService preferencesService, + ISentMailReceiptService sentMailReceiptService) : base(databaseService) { _folderService = folderService; _contactService = contactService; @@ -48,6 +50,7 @@ public class MailService : BaseDatabaseService, IMailService _signatureService = signatureService; _mimeFileService = mimeFileService; _preferencesService = preferencesService; + _sentMailReceiptService = sentMailReceiptService; } public async Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions) @@ -363,6 +366,7 @@ public class MailService : BaseDatabaseService, IMailService // 5. Assign all properties synchronously from the pre-loaded in-memory caches - no DB calls here. AssignPropertiesFromCaches(mails, folderCache, accountCache, contactCache); mails.RemoveAll(m => m.AssignedAccount == null || m.AssignedFolder == null); + await _sentMailReceiptService.PopulateReceiptStatesAsync(mails).ConfigureAwait(false); if (!options.CreateThreads || mails.Count == 0) return [.. mails]; @@ -418,6 +422,8 @@ public class MailService : BaseDatabaseService, IMailService mails.AddRange(threadMails.Where(m => m.AssignedAccount != null && m.AssignedFolder != null)); } + await _sentMailReceiptService.PopulateReceiptStatesAsync(mails).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); return [.. mails]; } @@ -568,6 +574,7 @@ public class MailService : BaseDatabaseService, IMailService mailCopy.AssignedAccount = account; mailCopy.AssignedFolder = folder; mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false); + await _sentMailReceiptService.PopulateReceiptStateAsync(mailCopy).ConfigureAwait(false); } public async Task GetSingleMailItemWithoutFolderAssignmentAsync(string mailCopyId) @@ -843,6 +850,8 @@ public class MailService : BaseDatabaseService, IMailService var insertMailTask = InsertMailAsync(mailCopy); await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false); + await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); + await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); } public async Task CreateMailAsyncEx(Guid accountId, NewMailItemPackage package) @@ -919,6 +928,8 @@ public class MailService : BaseDatabaseService, IMailService mailCopy.UniqueId = existingCopyItem.UniqueId; await UpdateMailAsync(mailCopy).ConfigureAwait(false); + await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); + await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); return false; } @@ -933,6 +944,8 @@ public class MailService : BaseDatabaseService, IMailService } await InsertMailAsync(mailCopy).ConfigureAwait(false); + await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); + await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); return true; } diff --git a/Wino.Services/SentMailReceiptService.cs b/Wino.Services/SentMailReceiptService.cs new file mode 100644 index 00000000..49c4ab24 --- /dev/null +++ b/Wino.Services/SentMailReceiptService.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Extensions; +using Wino.Core.Domain.Interfaces; +using Wino.Messaging.UI; + +namespace Wino.Services; + +public class SentMailReceiptService( + IDatabaseService databaseService, + IFolderService folderService, + IAccountService accountService) : BaseDatabaseService(databaseService), ISentMailReceiptService +{ + public async Task PopulateReceiptStateAsync(MailCopy mailCopy) + { + if (mailCopy == null) + return; + + var state = await Connection.FindAsync(mailCopy.UniqueId).ConfigureAwait(false); + ApplyState(mailCopy, state); + } + + public async Task PopulateReceiptStatesAsync(IReadOnlyCollection mailCopies) + { + if (mailCopies == null || mailCopies.Count == 0) + return; + + var uniqueIds = mailCopies + .Select(m => m.UniqueId) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + + if (uniqueIds.Count == 0) + return; + + var placeholders = string.Join(",", uniqueIds.Select(_ => "?")); + var states = await Connection.QueryAsync( + $"SELECT * FROM {nameof(SentMailReceiptState)} WHERE {nameof(SentMailReceiptState.MailUniqueId)} IN ({placeholders})", + uniqueIds.Cast().ToArray()).ConfigureAwait(false); + + var stateLookup = states.ToDictionary(s => s.MailUniqueId); + + foreach (var mailCopy in mailCopies) + { + stateLookup.TryGetValue(mailCopy.UniqueId, out var state); + ApplyState(mailCopy, state); + } + } + + public async Task TrackSentMailAsync(MailCopy mailCopy, MimeKit.MimeMessage mimeMessage = null) + { + if (mailCopy?.AssignedFolder == null || mailCopy.AssignedAccount == null) + return; + + if (mailCopy.AssignedFolder.SpecialFolderType != SpecialFolderType.Sent) + return; + + var isRequested = mailCopy.IsReadReceiptRequested || mimeMessage.HasReadReceiptRequest(); + if (!isRequested || string.IsNullOrWhiteSpace(mailCopy.MessageId)) + return; + + var existing = await Connection.FindAsync(mailCopy.UniqueId).ConfigureAwait(false); + + if (existing == null) + { + existing = new SentMailReceiptState + { + MailUniqueId = mailCopy.UniqueId, + AccountId = mailCopy.AssignedAccount.Id, + MessageId = MailHeaderExtensions.NormalizeMessageId(mailCopy.MessageId), + IsReceiptRequested = true, + RequestedAtUtc = mailCopy.CreationDate == default ? DateTime.UtcNow : mailCopy.CreationDate, + Status = SentMailReceiptStatus.Requested + }; + + await Connection.InsertAsync(existing, typeof(SentMailReceiptState)).ConfigureAwait(false); + } + else + { + existing.AccountId = mailCopy.AssignedAccount.Id; + existing.MessageId = MailHeaderExtensions.NormalizeMessageId(mailCopy.MessageId); + existing.IsReceiptRequested = true; + if (existing.Status == SentMailReceiptStatus.None) + existing.Status = SentMailReceiptStatus.Requested; + + await Connection.UpdateAsync(existing, typeof(SentMailReceiptState)).ConfigureAwait(false); + } + + ApplyState(mailCopy, existing); + ReportUIChange(new MailUpdatedMessage(mailCopy, EntityUpdateSource.Server, MailCopyChangeFlags.ReadReceiptState)); + } + + public async Task ProcessIncomingReceiptAsync(MailCopy receiptMail, MimeKit.MimeMessage mimeMessage) + { + if (receiptMail?.AssignedAccount == null || mimeMessage == null) + return; + + var parsedReceipt = mimeMessage.ParseReadReceipt(); + if (!parsedReceipt.IsReadReceipt || string.IsNullOrWhiteSpace(parsedReceipt.OriginalMessageId)) + return; + + var targetMail = await Connection.FindWithQueryAsync( + "SELECT MailCopy.* FROM MailCopy " + + "INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id " + + "WHERE MailItemFolder.MailAccountId = ? AND MailCopy.MessageId = ? AND MailItemFolder.SpecialFolderType = ? " + + "ORDER BY MailCopy.CreationDate DESC LIMIT 1", + receiptMail.AssignedAccount.Id, + parsedReceipt.OriginalMessageId, + SpecialFolderType.Sent).ConfigureAwait(false); + + if (targetMail == null) + return; + + var state = await Connection.FindAsync(targetMail.UniqueId).ConfigureAwait(false) + ?? new SentMailReceiptState + { + MailUniqueId = targetMail.UniqueId, + AccountId = receiptMail.AssignedAccount.Id, + MessageId = parsedReceipt.OriginalMessageId, + RequestedAtUtc = targetMail.CreationDate == default ? DateTime.UtcNow : targetMail.CreationDate, + IsReceiptRequested = true, + Status = SentMailReceiptStatus.Requested + }; + + state.AccountId = receiptMail.AssignedAccount.Id; + state.MessageId = parsedReceipt.OriginalMessageId; + state.IsReceiptRequested = true; + state.Status = SentMailReceiptStatus.Acknowledged; + state.AcknowledgedAtUtc = parsedReceipt.AcknowledgedAtUtc ?? DateTime.UtcNow; + state.ReceiptMessageUniqueId = receiptMail.UniqueId; + + if (await Connection.FindAsync(state.MailUniqueId).ConfigureAwait(false) == null) + await Connection.InsertAsync(state, typeof(SentMailReceiptState)).ConfigureAwait(false); + else + await Connection.UpdateAsync(state, typeof(SentMailReceiptState)).ConfigureAwait(false); + + var folder = await folderService.GetFolderAsync(targetMail.FolderId).ConfigureAwait(false); + if (folder == null) + return; + + var account = await accountService.GetAccountAsync(folder.MailAccountId).ConfigureAwait(false); + targetMail.AssignedFolder = folder; + targetMail.AssignedAccount = account; + ApplyState(targetMail, state); + + ReportUIChange(new MailUpdatedMessage(targetMail, EntityUpdateSource.Server, MailCopyChangeFlags.ReadReceiptState)); + } + + private static void ApplyState(MailCopy mailCopy, SentMailReceiptState state) + { + mailCopy.IsReadReceiptRequested = state?.IsReceiptRequested ?? false; + mailCopy.ReadReceiptStatus = state?.Status ?? SentMailReceiptStatus.None; + mailCopy.ReadReceiptAcknowledgedAtUtc = state?.AcknowledgedAtUtc; + mailCopy.ReadReceiptMessageUniqueId = state?.ReceiptMessageUniqueId; + } +} diff --git a/Wino.Services/ServicesContainerSetup.cs b/Wino.Services/ServicesContainerSetup.cs index 96db250d..aad6fa4e 100644 --- a/Wino.Services/ServicesContainerSetup.cs +++ b/Wino.Services/ServicesContainerSetup.cs @@ -19,6 +19,7 @@ public static class ServicesContainerSetup services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient();