Add read receipt tracking for sent mail
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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -592,24 +593,47 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|
||||
if (message != null)
|
||||
{
|
||||
// Create MailCopy from metadata only
|
||||
var mailCopy = await CreateMailCopyFromMessageAsync(message, folder).ConfigureAwait(false);
|
||||
var itemType = Account.IsCalendarAccessGranted ? message.GetMailItemType() : MailItemType.Mail;
|
||||
|
||||
if (mailCopy != null)
|
||||
if (ShouldDownloadMimeForMessage(message, folder, itemType))
|
||||
{
|
||||
// Create package without MIME
|
||||
var contacts = ExtractContactsFromOutlookMessage(message);
|
||||
var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId, contacts);
|
||||
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||
var packages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (isInserted)
|
||||
if (packages != null)
|
||||
{
|
||||
downloadedIds.Add(mailCopy.Id);
|
||||
_logger.Debug("Downloaded metadata for message {MailId} in folder {FolderName}", messageId, folder.FolderName);
|
||||
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
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create MailCopy from metadata only
|
||||
var mailCopy = await CreateMailCopyFromMessageAsync(message, folder).ConfigureAwait(false);
|
||||
|
||||
if (mailCopy != null)
|
||||
{
|
||||
_logger.Warning("Failed to insert mail {MailId} for folder {FolderName}", messageId, folder.FolderName);
|
||||
// Create package without MIME
|
||||
var contacts = ExtractContactsFromOutlookMessage(message);
|
||||
var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId, contacts);
|
||||
bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||
|
||||
if (isInserted)
|
||||
{
|
||||
downloadedIds.Add(mailCopy.Id);
|
||||
_logger.Debug("Downloaded metadata for message {MailId} in folder {FolderName}", messageId, folder.FolderName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("Failed to insert mail {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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user