Add read receipt tracking for sent mail

This commit is contained in:
Burak Kaan Köse
2026-04-11 21:02:51 +02:00
parent d5c121ce24
commit 230039cb57
29 changed files with 690 additions and 21 deletions
+2
View File
@@ -6,6 +6,8 @@ public static class Constants
/// MIME header that exists in all the drafts created from Wino.
/// </summary>
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 = "___";
@@ -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<Guid> GetContainingIds() => [UniqueId];
public override string ToString() => $"{Subject} <-> {Id}";
}
@@ -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; }
}
@@ -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
}
@@ -0,0 +1,9 @@
namespace Wino.Core.Domain.Enums;
public enum SentMailReceiptStatus
{
None = 0,
Requested = 1,
Acknowledged = 2,
FailedToCorrelate = 3
}
@@ -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<string> 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<string> 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);
}
@@ -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; }
}
@@ -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<MailCopy> mailCopies);
Task TrackSentMailAsync(MailCopy mailCopy, MimeMessage mimeMessage = null);
Task ProcessIncomingReceiptAsync(MailCopy receiptMail, MimeMessage mimeMessage);
}
@@ -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.",
@@ -50,6 +50,7 @@ public class InMemoryDatabaseService : IDatabaseService
await Connection.CreateTableAsync<CalendarAttachment>();
await Connection.CreateTableAsync<Reminder>();
await Connection.CreateTableAsync<MailInvitationCalendarMapping>();
await Connection.CreateTableAsync<SentMailReceiptState>();
await Connection.CreateTableAsync<WinoAccount>();
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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: <original@example.com>\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<IFolderService>(MockBehavior.Strict);
folderService.Setup(x => x.GetFolderAsync(sentFolder.Id)).ReturnsAsync(sentFolder);
var accountService = new Mock<IAccountService>(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: <original@example.com>\r\nDisposition: manual-action/MDN-sent-manually; displayed\r\n")
};
await service.ProcessIncomingReceiptAsync(receiptMail, receiptMime);
var receiptState = await db.Connection.FindAsync<SentMailReceiptState>(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;
}
}
@@ -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
{
@@ -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,
};
+19 -2
View File
@@ -2100,6 +2100,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
IsDraft = isDraft,
HasAttachments = gmailMessage.Payload?.Parts?.Any(p => !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<IClientServiceRequest, Message
if (string.IsNullOrEmpty(copy.MessageId))
copy.MessageId = MailHeaderExtensions.NormalizeMessageId(mime.Headers[HeaderId.MessageId]);
if (!copy.IsReadReceiptRequested)
copy.IsReadReceiptRequested = mime.HasReadReceiptRequest();
if (string.IsNullOrEmpty(copy.InReplyTo))
copy.InReplyTo = MailHeaderExtensions.NormalizeMessageId(mime.InReplyTo);
@@ -2231,6 +2235,17 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
return ("", emailOnly);
}
private static bool HasReadReceiptRequest(IList<MessagePartHeader> headers)
=> headers?.Any(h => h.Name.Equals(Domain.Constants.DispositionNotificationToHeader, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(h.Value)) == true;
private static bool LooksLikeReadReceipt(IList<MessagePartHeader> 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<AccountContact> ExtractContactsFromGmailMessage(Message message, MimeMessage mimeMessage)
{
var contacts = new Dictionary<string, AccountContact>(StringComparer.OrdinalIgnoreCase);
@@ -2348,7 +2363,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Initial sync metadata flow does not include MIME, but calendar invitations need MIME
// for date rendering and invitation-to-calendar mapping.
if (mimeMessage == null && baseMailCopy?.ItemType == MailItemType.CalendarInvitation && !string.IsNullOrEmpty(message?.Id))
if (mimeMessage == null &&
(baseMailCopy?.ItemType == MailItemType.CalendarInvitation || LooksLikeReadReceipt(message?.Payload?.Headers)) &&
!string.IsNullOrEmpty(message?.Id))
{
try
{
@@ -2363,7 +2380,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to fetch raw MIME for calendar invitation {MessageId}", message.Id);
_logger.Warning(ex, "Failed to fetch raw MIME for Gmail message {MessageId}", message.Id);
}
}
+40 -1
View File
@@ -90,6 +90,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
"Flag",
"Importance",
"IsRead",
"IsReadReceiptRequested",
"IsDraft",
"ReceivedDateTime",
"HasAttachments",
@@ -401,7 +402,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
{
// For drafts and calendar invitations, download MIME during initial sync like delta sync.
var itemType = Account.IsCalendarAccessGranted ? message.GetMailItemType() : MailItemType.Mail;
if (folder.SpecialFolderType == SpecialFolderType.Draft || itemType == MailItemType.CalendarInvitation)
if (ShouldDownloadMimeForMessage(message, folder, itemType))
{
var draftPackages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false);
@@ -591,6 +592,28 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
var message = await batchResponse.GetResponseByIdAsync<Message>(batchRequestId).ConfigureAwait(false);
if (message != null)
{
var itemType = Account.IsCalendarAccessGranted ? message.GetMailItemType() : MailItemType.Mail;
if (ShouldDownloadMimeForMessage(message, folder, itemType))
{
var packages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false);
if (packages != null)
{
foreach (var package in packages)
{
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
if (isInserted)
{
downloadedIds.Add(package.Copy.Id);
_logger.Debug("Downloaded MIME-backed message {MailId} in folder {FolderName}", messageId, folder.FolderName);
}
}
}
}
else
{
// Create MailCopy from metadata only
var mailCopy = await CreateMailCopyFromMessageAsync(message, folder).ConfigureAwait(false);
@@ -613,6 +636,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
}
}
}
}
else
{
_logger.Warning("Failed to deserialize message {MailId} for folder {FolderName}", messageId, folder.FolderName);
@@ -685,6 +709,21 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
/// Creates a MailCopy from an Outlook Message with metadata only (centralized method).
/// This replaces the scattered CreateMinimalMailCopyAsync and AsMailCopy calls.
/// </summary>
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<MailCopy> CreateMailCopyFromMessageAsync(Message message, MailItemFolder assignedFolder)
{
if (message == null) return null;
@@ -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<AccountContact> addresses, InternetAddressList list)
{
list.Clear();
@@ -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,
@@ -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));
@@ -100,6 +100,12 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
/// </summary>
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;
/// <summary>
/// Gets whether any email in this thread is a draft
/// </summary>
@@ -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));
@@ -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"
};
}
}
@@ -234,6 +234,15 @@
Orientation="Horizontal"
Spacing="2">
<TextBlock
Margin="0,0,4,0"
VerticalAlignment="Center"
FontSize="11"
Opacity="0.8"
Text="{x:Bind MailItemInformation.ReadReceiptDisplayText, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
Visibility="{x:Bind helpers:XamlHelpers.StringToVisibilityConverter(MailItemInformation.ReadReceiptDisplayText), Mode=OneWay}" />
<ContentPresenter
x:Name="HasAttachmentContent"
x:Load="{x:Bind MailItemInformation.HasAttachments, Mode=OneWay}"
@@ -302,6 +302,19 @@
</Viewbox>
</ToggleButton.Content>
</ToggleButton>
<ToggleButton
Margin="8,0,0,0"
IsChecked="{x:Bind ViewModel.IsReadReceiptRequested, Mode=TwoWay}"
ToolTipService.ToolTip="{x:Bind domain:Translator.Composer_RequestReadReceipt}">
<ToggleButton.Content>
<Viewbox
Width="16"
Height="16"
Margin="0,0,4,0">
<PathIcon Data="M10.75 2.5a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.5 9.25a8.25 8.25 0 1 1 15.036 4.71l2.507 2.507a.75.75 0 1 1-1.06 1.06l-2.507-2.507A8.25 8.25 0 0 1 2.5 9.25Zm8.25-3.5a.75.75 0 0 1 .75.75v2.44l1.78 1.187a.75.75 0 0 1-.832 1.248l-2.114-1.41a.75.75 0 0 1-.334-.624V6.5a.75.75 0 0 1 .75-.75Z" />
</Viewbox>
</ToggleButton.Content>
</ToggleButton>
</StackPanel>
</controls1:EditorTabbedCommandBarControl.OptionsCustomContent>
</controls1:EditorTabbedCommandBarControl>
+4
View File
@@ -68,6 +68,7 @@ public class DatabaseService : IDatabaseService
Connection.CreateTableAsync<CalendarAttachment>(),
Connection.CreateTableAsync<Reminder>(),
Connection.CreateTableAsync<MailInvitationCalendarMapping>(),
Connection.CreateTableAsync<SentMailReceiptState>(),
Connection.CreateTableAsync<WinoAccount>());
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);
}
}
@@ -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,
+14 -1
View File
@@ -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<MailService>();
@@ -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<MailCopy> 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;
}
+161
View File
@@ -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<SentMailReceiptState>(mailCopy.UniqueId).ConfigureAwait(false);
ApplyState(mailCopy, state);
}
public async Task PopulateReceiptStatesAsync(IReadOnlyCollection<MailCopy> 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<SentMailReceiptState>(
$"SELECT * FROM {nameof(SentMailReceiptState)} WHERE {nameof(SentMailReceiptState.MailUniqueId)} IN ({placeholders})",
uniqueIds.Cast<object>().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<SentMailReceiptState>(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<MailCopy>(
"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<SentMailReceiptState>(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<SentMailReceiptState>(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;
}
}
+1
View File
@@ -19,6 +19,7 @@ public static class ServicesContainerSetup
services.AddTransient<ICalendarService, CalendarService>();
services.AddTransient<IMailService, MailService>();
services.AddTransient<ISentMailReceiptService, SentMailReceiptService>();
services.AddTransient<IFolderService, FolderService>();
services.AddTransient<IAccountService, AccountService>();
services.AddTransient<IContactService, ContactService>();