Merge read receipt tracking work

This commit is contained in:
Burak Kaan Köse
2026-04-11 21:03:22 +02:00
29 changed files with 692 additions and 21 deletions
+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);
@@ -234,5 +235,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>();