Merge read receipt tracking work
This commit is contained in:
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user