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
+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);
}
@@ -1108,6 +1108,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",
@@ -1174,6 +1175,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.",