Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d66015bebd | |||
| 890bfc84f1 | |||
| 59505d6985 | |||
| 09820dda71 | |||
| c0023614ad |
@@ -106,10 +106,6 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
|
|||||||
|
|
||||||
return await GenerateTokenInformationAsync(account);
|
return await GenerateTokenInformationAsync(account);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
|
public async Task<TokenInformationEx> GenerateTokenInformationAsync(MailAccount account)
|
||||||
@@ -128,14 +124,8 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
|
|||||||
.AcquireTokenInteractive(GetScope(account))
|
.AcquireTokenInteractive(GetScope(account))
|
||||||
.ExecuteAsync();
|
.ExecuteAsync();
|
||||||
|
|
||||||
// If the account is null, it means it's the initial creation of it.
|
// Microsoft 365 work/school tenants can use a sign-in UPN that differs from
|
||||||
// If not, make sure the authenticated user address matches the username.
|
// the mailbox primary SMTP address, so interactive reauth must not reject them.
|
||||||
// When people refresh their token, accounts must match.
|
|
||||||
|
|
||||||
if (account?.Address != null && !account.Address.Equals(authResult.Account.Username, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
throw new AuthenticationException("Authenticated address does not match with your account address. If you are signing with a Office365, it is not officially supported yet.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
|
return new TokenInformationEx(authResult.AccessToken, authResult.Account.Username);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ public class MailCopy
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsFlagged { get; set; }
|
public bool IsFlagged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this mail should stay pinned to the top locally.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPinned { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// To support Outlook.
|
/// To support Outlook.
|
||||||
/// Gmail doesn't use it.
|
/// Gmail doesn't use it.
|
||||||
@@ -167,6 +172,9 @@ public class MailCopy
|
|||||||
[Ignore]
|
[Ignore]
|
||||||
public Guid? ReadReceiptMessageUniqueId { get; set; }
|
public Guid? ReadReceiptMessageUniqueId { get; set; }
|
||||||
|
|
||||||
|
[Ignore]
|
||||||
|
public List<MailCategory> Categories { get; set; } = [];
|
||||||
|
|
||||||
public IEnumerable<Guid> GetContainingIds() => [UniqueId];
|
public IEnumerable<Guid> GetContainingIds() => [UniqueId];
|
||||||
public override string ToString() => $"{Subject} <-> {Id}";
|
public override string ToString() => $"{Subject} <-> {Id}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,17 +20,19 @@ public enum MailCopyChangeFlags
|
|||||||
Importance = 1 << 11,
|
Importance = 1 << 11,
|
||||||
IsRead = 1 << 12,
|
IsRead = 1 << 12,
|
||||||
IsFlagged = 1 << 13,
|
IsFlagged = 1 << 13,
|
||||||
IsFocused = 1 << 14,
|
IsPinned = 1 << 14,
|
||||||
HasAttachments = 1 << 15,
|
IsFocused = 1 << 15,
|
||||||
ItemType = 1 << 16,
|
HasAttachments = 1 << 16,
|
||||||
DraftId = 1 << 17,
|
ItemType = 1 << 17,
|
||||||
IsDraft = 1 << 18,
|
DraftId = 1 << 18,
|
||||||
FileId = 1 << 19,
|
IsDraft = 1 << 19,
|
||||||
AssignedFolder = 1 << 20,
|
FileId = 1 << 20,
|
||||||
AssignedAccount = 1 << 21,
|
AssignedFolder = 1 << 21,
|
||||||
SenderContact = 1 << 22,
|
AssignedAccount = 1 << 22,
|
||||||
UniqueId = 1 << 23,
|
SenderContact = 1 << 23,
|
||||||
ReadReceiptState = 1 << 24,
|
UniqueId = 1 << 24,
|
||||||
|
ReadReceiptState = 1 << 25,
|
||||||
|
Categories = 1 << 26,
|
||||||
All = Id |
|
All = Id |
|
||||||
FolderId |
|
FolderId |
|
||||||
ThreadId |
|
ThreadId |
|
||||||
@@ -45,6 +47,7 @@ public enum MailCopyChangeFlags
|
|||||||
Importance |
|
Importance |
|
||||||
IsRead |
|
IsRead |
|
||||||
IsFlagged |
|
IsFlagged |
|
||||||
|
IsPinned |
|
||||||
IsFocused |
|
IsFocused |
|
||||||
HasAttachments |
|
HasAttachments |
|
||||||
ItemType |
|
ItemType |
|
||||||
@@ -55,5 +58,6 @@ public enum MailCopyChangeFlags
|
|||||||
AssignedAccount |
|
AssignedAccount |
|
||||||
SenderContact |
|
SenderContact |
|
||||||
UniqueId |
|
UniqueId |
|
||||||
ReadReceiptState
|
ReadReceiptState |
|
||||||
|
Categories
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public interface IMailCategoryService
|
|||||||
Task AssignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds);
|
Task AssignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds);
|
||||||
Task UnassignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds);
|
Task UnassignCategoryAsync(Guid categoryId, IEnumerable<Guid> mailCopyUniqueIds);
|
||||||
Task<List<MailCategory>> GetCategoriesForMailAsync(Guid accountId, IEnumerable<Guid> mailCopyUniqueIds);
|
Task<List<MailCategory>> GetCategoriesForMailAsync(Guid accountId, IEnumerable<Guid> mailCopyUniqueIds);
|
||||||
|
Task<IReadOnlyDictionary<Guid, IReadOnlyList<MailCategory>>> GetCategoriesByMailAsync(Guid accountId, IEnumerable<Guid> mailCopyUniqueIds);
|
||||||
Task<List<Guid>> GetAssignedCategoryIdsForAllAsync(IEnumerable<Guid> mailCopyUniqueIds);
|
Task<List<Guid>> GetAssignedCategoryIdsForAllAsync(IEnumerable<Guid> mailCopyUniqueIds);
|
||||||
Task<List<string>> GetCategoryNamesForMailAsync(Guid mailCopyUniqueId);
|
Task<List<string>> GetCategoryNamesForMailAsync(Guid mailCopyUniqueId);
|
||||||
Task<List<MailCopy>> GetMailCopiesForCategoryAsync(Guid categoryId);
|
Task<List<MailCopy>> GetMailCopiesForCategoryAsync(Guid categoryId);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Interfaces;
|
namespace Wino.Core.Domain.Interfaces;
|
||||||
@@ -27,4 +29,6 @@ public interface IMailItemDisplayInformation : INotifyPropertyChanged
|
|||||||
bool HasReadReceiptTracking { get; }
|
bool HasReadReceiptTracking { get; }
|
||||||
bool IsReadReceiptAcknowledged { get; }
|
bool IsReadReceiptAcknowledged { get; }
|
||||||
string ReadReceiptDisplayText { get; }
|
string ReadReceiptDisplayText { get; }
|
||||||
|
IReadOnlyList<MailCategory> Categories { get; }
|
||||||
|
bool HasCategories { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ public interface IMailListItemSorting
|
|||||||
{
|
{
|
||||||
DateTime SortingDate { get; }
|
DateTime SortingDate { get; }
|
||||||
string SortingName { get; }
|
string SortingName { get; }
|
||||||
|
bool IsPinned { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public interface IMailService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<MailCopy>> GetMailItemsAsync(IEnumerable<string> mailCopyIds);
|
Task<List<MailCopy>> GetMailItemsAsync(IEnumerable<string> mailCopyIds);
|
||||||
Task<List<MailCopy>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default);
|
Task<List<MailCopy>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default);
|
||||||
|
Task<List<MailCopy>> FetchPinnedMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes all mail copies for all folders.
|
/// Deletes all mail copies for all folders.
|
||||||
@@ -36,6 +37,7 @@ public interface IMailService
|
|||||||
|
|
||||||
Task ChangeReadStatusAsync(string mailCopyId, bool isRead);
|
Task ChangeReadStatusAsync(string mailCopyId, bool isRead);
|
||||||
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
|
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
|
||||||
|
Task ChangePinnedStatusAsync(IEnumerable<Guid> uniqueMailIds, bool isPinned);
|
||||||
Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates);
|
Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates);
|
||||||
|
|
||||||
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
|
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Models.Calendar;
|
namespace Wino.Core.Domain.Models.Calendar;
|
||||||
|
|
||||||
public sealed class CalDavCalendar
|
public sealed class CalDavCalendar
|
||||||
{
|
{
|
||||||
public string RemoteCalendarId { get; init; } = string.Empty;
|
public string RemoteCalendarId { get; init; } = string.Empty;
|
||||||
public string Name { get; init; } = string.Empty;
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public string Description { get; init; } = string.Empty;
|
||||||
public string CTag { get; init; } = string.Empty;
|
public string CTag { get; init; } = string.Empty;
|
||||||
public string SyncToken { get; init; } = string.Empty;
|
public string SyncToken { get; init; } = string.Empty;
|
||||||
|
public string TimeZone { get; init; } = string.Empty;
|
||||||
|
public string BackgroundColorHex { get; init; } = string.Empty;
|
||||||
|
public bool IsReadOnly { get; init; }
|
||||||
|
public bool SupportsEvents { get; init; } = true;
|
||||||
|
public CalendarItemShowAs DefaultShowAs { get; init; } = CalendarItemShowAs.Busy;
|
||||||
|
public double? Order { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
|
|
||||||
public class ListItemComparer : IComparer<object>
|
public class ListItemComparer : IComparer<object>
|
||||||
{
|
{
|
||||||
@@ -9,14 +10,48 @@ public class ListItemComparer : IComparer<object>
|
|||||||
|
|
||||||
public int Compare(object x, object y)
|
public int Compare(object x, object y)
|
||||||
{
|
{
|
||||||
|
if (x is MailListGroupKey xGroupKey && y is MailListGroupKey yGroupKey)
|
||||||
|
{
|
||||||
|
if (xGroupKey.IsPinned != yGroupKey.IsPinned)
|
||||||
|
return yGroupKey.IsPinned.CompareTo(xGroupKey.IsPinned);
|
||||||
|
|
||||||
|
if (xGroupKey.IsPinned && yGroupKey.IsPinned)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return CompareSortValues(xGroupKey.Value, yGroupKey.Value);
|
||||||
|
}
|
||||||
|
|
||||||
if (x is IMailListItemSorting xSorting && y is IMailListItemSorting ySorting)
|
if (x is IMailListItemSorting xSorting && y is IMailListItemSorting ySorting)
|
||||||
return SortByName ? string.Compare(xSorting.SortingName, ySorting.SortingName, StringComparison.OrdinalIgnoreCase) : DateTime.Compare(ySorting.SortingDate, xSorting.SortingDate);
|
{
|
||||||
else if (x is MailCopy xMail && y is MailCopy yMail)
|
if (xSorting.IsPinned != ySorting.IsPinned)
|
||||||
return SortByName ? string.Compare(xMail.FromName, yMail.FromName, StringComparison.OrdinalIgnoreCase) : DateTime.Compare(yMail.CreationDate, xMail.CreationDate);
|
return ySorting.IsPinned.CompareTo(xSorting.IsPinned);
|
||||||
else if (x is DateTime dateX && y is DateTime dateY)
|
|
||||||
|
return SortByName
|
||||||
|
? string.Compare(xSorting.SortingName, ySorting.SortingName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
: DateTime.Compare(ySorting.SortingDate, xSorting.SortingDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x is MailCopy xMail && y is MailCopy yMail)
|
||||||
|
{
|
||||||
|
if (xMail.IsPinned != yMail.IsPinned)
|
||||||
|
return yMail.IsPinned.CompareTo(xMail.IsPinned);
|
||||||
|
|
||||||
|
return SortByName
|
||||||
|
? string.Compare(xMail.FromName, yMail.FromName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
: DateTime.Compare(yMail.CreationDate, xMail.CreationDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompareSortValues(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareSortValues(object x, object y)
|
||||||
|
{
|
||||||
|
if (x is DateTime dateX && y is DateTime dateY)
|
||||||
return DateTime.Compare(dateY, dateX);
|
return DateTime.Compare(dateY, dateX);
|
||||||
else if (x is string stringX && y is string stringY)
|
|
||||||
|
if (x is string stringX && y is string stringY)
|
||||||
return stringY.CompareTo(stringX);
|
return stringY.CompareTo(stringX);
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public class HtmlPreviewVisitor : MimeVisitor
|
|||||||
{
|
{
|
||||||
private static readonly HashSet<string> BlockedTags = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> BlockedTags = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"script", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "meta", "form"
|
"script", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "meta", "form", "link"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly HashSet<string> AllowedDataImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> AllowedDataImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Wino.Core.Domain.Models.MailItem;
|
||||||
|
|
||||||
|
public sealed record MailListGroupKey(bool IsPinned, object Value)
|
||||||
|
{
|
||||||
|
public static MailListGroupKey Pinned { get; } = new(true, null);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public class HtmlPreviewVisitorTests
|
|||||||
<html>
|
<html>
|
||||||
<body onload="alert('x')">
|
<body onload="alert('x')">
|
||||||
<h1 onclick="evil()">hello</h1>
|
<h1 onclick="evil()">hello</h1>
|
||||||
|
<link rel="stylesheet" href="https://tracker.example/mail.css" />
|
||||||
<script>alert('xss')</script>
|
<script>alert('xss')</script>
|
||||||
<iframe src="https://malicious.example"></iframe>
|
<iframe src="https://malicious.example"></iframe>
|
||||||
<object data="https://malicious.example/file.swf"></object>
|
<object data="https://malicious.example/file.swf"></object>
|
||||||
@@ -34,6 +35,7 @@ public class HtmlPreviewVisitorTests
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
output.Should().NotContain("<script", "script tags must be blocked in rendered html");
|
output.Should().NotContain("<script", "script tags must be blocked in rendered html");
|
||||||
|
output.Should().NotContain("<link", "external stylesheet tags must be blocked in rendered html");
|
||||||
output.Should().NotContain("<iframe", "iframe tags must be blocked in rendered html");
|
output.Should().NotContain("<iframe", "iframe tags must be blocked in rendered html");
|
||||||
output.Should().NotContain("<object", "object tags must be blocked in rendered html");
|
output.Should().NotContain("<object", "object tags must be blocked in rendered html");
|
||||||
output.Should().NotContain("onload=", "event handler attributes must be stripped");
|
output.Should().NotContain("onload=", "event handler attributes must be stripped");
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
using Wino.Services.Extensions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Tests.Services;
|
||||||
|
|
||||||
|
public class HtmlAgilityPackExtensionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ClearImages_Should_Block_Remote_Image_References_But_Keep_Embedded_Ones()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var document = new HtmlDocument();
|
||||||
|
document.LoadHtml("""
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
.hero { background-image: url('https://tracker.example/bg.png'); color: red; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body background="https://tracker.example/body.png">
|
||||||
|
<img id="remote" src="https://tracker.example/pixel.png" />
|
||||||
|
<img id="embedded" src="data:image/png;base64,AAAA" />
|
||||||
|
<img id="responsive" srcset="https://tracker.example/1x.png 1x, data:image/png;base64,BBBB 2x" />
|
||||||
|
<div id="inline-style" style="background-image:url('https://tracker.example/inline.png');color:blue;">hello</div>
|
||||||
|
<v:fill id="vml" src="https://tracker.example/vml.png"></v:fill>
|
||||||
|
<svg>
|
||||||
|
<image id="svg-remote" href="https://tracker.example/vector.svg"></image>
|
||||||
|
<use id="svg-local" href="#icon"></use>
|
||||||
|
</svg>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
document.ClearImages();
|
||||||
|
var output = document.DocumentNode.OuterHtml;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
output.Should().Contain("id=\"embedded\" src=\"data:image/png;base64,AAAA\"", "embedded inline images should still render");
|
||||||
|
output.Should().NotContain("id=\"remote\" src=", "remote img sources should be removed");
|
||||||
|
output.Should().NotContain("background=\"https://tracker.example/body.png\"", "background attributes can be used as trackers");
|
||||||
|
output.Should().NotContain("srcset=", "responsive image candidates should be removed because they may fetch remote trackers");
|
||||||
|
output.Should().NotContain("https://tracker.example/inline.png", "inline CSS should not be allowed to fetch remote images");
|
||||||
|
output.Should().Contain("color:blue", "non-image inline styling should be preserved");
|
||||||
|
output.Should().NotContain("https://tracker.example/bg.png", "style blocks should not be allowed to fetch remote images");
|
||||||
|
output.Should().Contain("color: red", "safe CSS declarations should remain");
|
||||||
|
output.Should().NotContain("id=\"vml\" src=", "VML image references should be removed");
|
||||||
|
output.Should().NotContain("id=\"svg-remote\" href=", "SVG image references should not fetch remote content");
|
||||||
|
output.Should().Contain("id=\"svg-local\" href=\"#icon\"", "local fragment references should remain");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,26 @@ public class MailFetchingTests : IAsyncLifetime
|
|||||||
result.Single().FolderId.Should().Be(_inboxFolder.Id, "a copy from the actively searched folder should win over newer non-searched copies");
|
result.Single().FolderId.Should().Be(_inboxFolder.Id, "a copy from the actively searched folder should win over newer non-searched copies");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchPinnedMailsAsync_ReturnsPinnedMailsOutsideRegularPage()
|
||||||
|
{
|
||||||
|
var oldPinned = BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddDays(-5));
|
||||||
|
oldPinned.IsPinned = true;
|
||||||
|
|
||||||
|
var recentMails = Enumerable.Range(0, 120)
|
||||||
|
.Select(i => BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddMinutes(-i)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await _databaseService.Connection.InsertAsync(oldPinned, typeof(MailCopy));
|
||||||
|
await _databaseService.Connection.InsertAllAsync(recentMails, typeof(MailCopy));
|
||||||
|
|
||||||
|
var options = BuildOptions([_inboxFolder], createThreads: false, take: 20);
|
||||||
|
|
||||||
|
var result = await _mailService.FetchPinnedMailsAsync(options);
|
||||||
|
|
||||||
|
result.Should().ContainSingle(mail => mail.UniqueId == oldPinned.UniqueId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAssignmentAsync_ExistingAssignment_IsIgnored()
|
public async Task CreateAssignmentAsync_ExistingAssignment_IsIgnored()
|
||||||
{
|
{
|
||||||
@@ -297,6 +317,27 @@ public class MailFetchingTests : IAsyncLifetime
|
|||||||
insertedCopies.Select(mail => mail.FolderId).Should().BeEquivalentTo([_inboxFolder.Id, archiveFolder.Id]);
|
insertedCopies.Select(mail => mail.FolderId).Should().BeEquivalentTo([_inboxFolder.Id, archiveFolder.Id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateMailAsync_PreservesLocalPinnedState()
|
||||||
|
{
|
||||||
|
var existingMail = BuildMail(_inboxFolder.Id, DateTime.UtcNow.AddHours(-1));
|
||||||
|
existingMail.IsPinned = true;
|
||||||
|
|
||||||
|
await _databaseService.Connection.InsertAsync(existingMail, typeof(MailCopy));
|
||||||
|
|
||||||
|
var refreshedMail = BuildMail(_inboxFolder.Id, DateTime.UtcNow, id: existingMail.Id);
|
||||||
|
refreshedMail.UniqueId = existingMail.UniqueId;
|
||||||
|
refreshedMail.FileId = existingMail.FileId;
|
||||||
|
refreshedMail.Subject = "Updated subject";
|
||||||
|
|
||||||
|
await _mailService.UpdateMailAsync(refreshedMail);
|
||||||
|
|
||||||
|
var storedMail = await _databaseService.Connection.FindAsync<MailCopy>(existingMail.UniqueId);
|
||||||
|
storedMail.Should().NotBeNull();
|
||||||
|
storedMail!.IsPinned.Should().BeTrue();
|
||||||
|
storedMail.Subject.Should().Be("Updated subject");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Performance: 1 000 mails / ~70 threads ─────────────────────────────────
|
// ── Performance: 1 000 mails / ~70 threads ─────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -327,6 +327,94 @@ public class MailThreadingTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangePinnedStatusAsync_SendsHydratedBulkMailUpdatedMessage()
|
||||||
|
{
|
||||||
|
var mail = new MailCopy
|
||||||
|
{
|
||||||
|
UniqueId = Guid.NewGuid(),
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
FolderId = _draftFolder.Id,
|
||||||
|
IsPinned = false,
|
||||||
|
Subject = "Pinned draft"
|
||||||
|
};
|
||||||
|
|
||||||
|
await _databaseService.Connection.InsertAsync(mail, typeof(MailCopy));
|
||||||
|
|
||||||
|
var recipient = new MailUpdateRecipient();
|
||||||
|
WeakReferenceMessenger.Default.Register<MailUpdatedMessage>(recipient);
|
||||||
|
WeakReferenceMessenger.Default.Register<BulkMailUpdatedMessage>(recipient);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mailService.ChangePinnedStatusAsync([mail.UniqueId], true);
|
||||||
|
|
||||||
|
recipient.SingleUpdates.Should().BeEmpty();
|
||||||
|
recipient.BulkUpdates.Should().ContainSingle();
|
||||||
|
recipient.BulkUpdates[0].ChangedProperties.Should().Be(MailCopyChangeFlags.IsPinned);
|
||||||
|
recipient.BulkUpdates[0].UpdatedMails.Should().ContainSingle();
|
||||||
|
|
||||||
|
var updatedMail = recipient.BulkUpdates[0].UpdatedMails[0];
|
||||||
|
updatedMail.IsPinned.Should().BeTrue();
|
||||||
|
updatedMail.AssignedFolder.Should().NotBeNull();
|
||||||
|
updatedMail.AssignedFolder!.Id.Should().Be(_draftFolder.Id);
|
||||||
|
updatedMail.AssignedAccount.Should().NotBeNull();
|
||||||
|
updatedMail.AssignedAccount!.Id.Should().Be(_account.Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
WeakReferenceMessenger.Default.Unregister<MailUpdatedMessage>(recipient);
|
||||||
|
WeakReferenceMessenger.Default.Unregister<BulkMailUpdatedMessage>(recipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAssignmentAsync_SendsHydratedMailAddedMessage()
|
||||||
|
{
|
||||||
|
var archiveFolder = new MailItemFolder
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
MailAccountId = _account.Id,
|
||||||
|
FolderName = "Archive",
|
||||||
|
RemoteFolderId = "archive",
|
||||||
|
SpecialFolderType = SpecialFolderType.Archive,
|
||||||
|
IsSystemFolder = true,
|
||||||
|
IsSynchronizationEnabled = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var mail = new MailCopy
|
||||||
|
{
|
||||||
|
UniqueId = Guid.NewGuid(),
|
||||||
|
Id = "assignment-mail",
|
||||||
|
FolderId = _draftFolder.Id,
|
||||||
|
Subject = "Assigned copy"
|
||||||
|
};
|
||||||
|
|
||||||
|
await _databaseService.Connection.InsertAsync(archiveFolder, typeof(MailItemFolder));
|
||||||
|
await _databaseService.Connection.InsertAsync(mail, typeof(MailCopy));
|
||||||
|
|
||||||
|
var recipient = new MailAddRecipient();
|
||||||
|
WeakReferenceMessenger.Default.Register<MailAddedMessage>(recipient);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mailService.CreateAssignmentAsync(_account.Id, mail.Id, archiveFolder.RemoteFolderId);
|
||||||
|
|
||||||
|
recipient.Added.Should().ContainSingle();
|
||||||
|
|
||||||
|
var addedMail = recipient.Added[0].AddedMail;
|
||||||
|
addedMail.UniqueId.Should().NotBe(mail.UniqueId);
|
||||||
|
addedMail.AssignedFolder.Should().NotBeNull();
|
||||||
|
addedMail.AssignedFolder!.Id.Should().Be(archiveFolder.Id);
|
||||||
|
addedMail.AssignedAccount.Should().NotBeNull();
|
||||||
|
addedMail.AssignedAccount!.Id.Should().Be(_account.Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
WeakReferenceMessenger.Default.Unregister<MailAddedMessage>(recipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static MimeMessage CreateReferencedMimeMessage(string subject, string? messageId = null)
|
private static MimeMessage CreateReferencedMimeMessage(string subject, string? messageId = null)
|
||||||
{
|
{
|
||||||
var message = new MimeMessage();
|
var message = new MimeMessage();
|
||||||
@@ -350,6 +438,13 @@ public class MailThreadingTests : IAsyncLifetime
|
|||||||
public void Receive(BulkMailUpdatedMessage message) => BulkUpdates.Add(message);
|
public void Receive(BulkMailUpdatedMessage message) => BulkUpdates.Add(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class MailAddRecipient : IRecipient<MailAddedMessage>
|
||||||
|
{
|
||||||
|
public List<MailAddedMessage> Added { get; } = [];
|
||||||
|
|
||||||
|
public void Receive(MailAddedMessage message) => Added.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class MailReadStatusRecipient : IRecipient<MailReadStatusChanged>, IRecipient<BulkMailReadStatusChanged>
|
internal sealed class MailReadStatusRecipient : IRecipient<MailReadStatusChanged>, IRecipient<BulkMailReadStatusChanged>
|
||||||
{
|
{
|
||||||
public List<MailReadStatusChanged> SingleUpdates { get; } = [];
|
public List<MailReadStatusChanged> SingleUpdates { get; } = [];
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
|
using Wino.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Tests.Services;
|
||||||
|
|
||||||
|
public class SpecialImapProviderConfigResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetServerInformation_ICloud_UsesMailboxLocalPartForIncomingAndOutgoingUsernames()
|
||||||
|
{
|
||||||
|
var sut = new SpecialImapProviderConfigResolver();
|
||||||
|
var account = new MailAccount
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Address = "tester@icloud.com"
|
||||||
|
};
|
||||||
|
var dialogResult = new AccountCreationDialogResult(
|
||||||
|
MailProviderType.IMAP4,
|
||||||
|
"iCloud",
|
||||||
|
new SpecialImapProviderDetails(
|
||||||
|
"tester@icloud.com",
|
||||||
|
"app-password",
|
||||||
|
"Tester",
|
||||||
|
SpecialImapProvider.iCloud,
|
||||||
|
ImapCalendarSupportMode.CalDav),
|
||||||
|
"#0078D4",
|
||||||
|
InitialSynchronizationRange.SixMonths,
|
||||||
|
true,
|
||||||
|
true);
|
||||||
|
|
||||||
|
var serverInformation = sut.GetServerInformation(account, dialogResult);
|
||||||
|
|
||||||
|
serverInformation.IncomingServerUsername.Should().Be("tester");
|
||||||
|
serverInformation.OutgoingServerUsername.Should().Be("tester");
|
||||||
|
serverInformation.CalDavUsername.Should().Be("tester@icloud.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.IO;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
using Wino.Core.Integration.Processors;
|
||||||
|
using Wino.Core.Misc;
|
||||||
|
using Wino.Core.Synchronizers.ImapSync;
|
||||||
|
using Wino.Core.Synchronizers.Mail;
|
||||||
|
using Wino.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Tests.Synchronizers;
|
||||||
|
|
||||||
|
public class CalDavCalendarMetadataTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParseCalendarCollection_MapsCollectionMetadataAndSkipsNonEventCalendars()
|
||||||
|
{
|
||||||
|
var xml = XDocument.Parse(
|
||||||
|
"""
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/calendars/work/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype>
|
||||||
|
<D:collection />
|
||||||
|
<C:calendar />
|
||||||
|
</D:resourcetype>
|
||||||
|
<D:displayname>Work</D:displayname>
|
||||||
|
<C:calendar-description>Team calendar</C:calendar-description>
|
||||||
|
<CS:getctag>"ctag-1"</CS:getctag>
|
||||||
|
<D:sync-token>sync-1</D:sync-token>
|
||||||
|
<D:current-user-privilege-set>
|
||||||
|
<D:privilege>
|
||||||
|
<D:read />
|
||||||
|
</D:privilege>
|
||||||
|
</D:current-user-privilege-set>
|
||||||
|
<C:calendar-timezone><![CDATA[
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Warsaw
|
||||||
|
END:VTIMEZONE
|
||||||
|
END:VCALENDAR
|
||||||
|
]]></C:calendar-timezone>
|
||||||
|
<C:supported-calendar-component-set>
|
||||||
|
<C:comp name="VEVENT" />
|
||||||
|
<C:comp name="VTODO" />
|
||||||
|
</C:supported-calendar-component-set>
|
||||||
|
<C:schedule-calendar-transp>
|
||||||
|
<C:transparent />
|
||||||
|
</C:schedule-calendar-transp>
|
||||||
|
<ICAL:calendar-color>#5b617aff</ICAL:calendar-color>
|
||||||
|
<ICAL:calendar-order>2</ICAL:calendar-order>
|
||||||
|
</D:prop>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
<D:response>
|
||||||
|
<D:href>/calendars/tasks/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype>
|
||||||
|
<D:collection />
|
||||||
|
<C:calendar />
|
||||||
|
</D:resourcetype>
|
||||||
|
<D:displayname>Tasks</D:displayname>
|
||||||
|
<C:supported-calendar-component-set>
|
||||||
|
<C:comp name="VTODO" />
|
||||||
|
</C:supported-calendar-component-set>
|
||||||
|
</D:prop>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>
|
||||||
|
""");
|
||||||
|
|
||||||
|
var calendars = ParseCalendars(xml, new Uri("https://calendar.example.com/"));
|
||||||
|
|
||||||
|
calendars.Should().ContainSingle();
|
||||||
|
|
||||||
|
var calendar = calendars[0];
|
||||||
|
calendar.RemoteCalendarId.Should().Be("https://calendar.example.com/calendars/work");
|
||||||
|
calendar.Name.Should().Be("Work");
|
||||||
|
calendar.Description.Should().Be("Team calendar");
|
||||||
|
calendar.CTag.Should().Be("\"ctag-1\"");
|
||||||
|
calendar.SyncToken.Should().Be("sync-1");
|
||||||
|
calendar.TimeZone.Should().Be("Europe/Warsaw");
|
||||||
|
calendar.BackgroundColorHex.Should().Be("#5B617A");
|
||||||
|
calendar.IsReadOnly.Should().BeTrue();
|
||||||
|
calendar.SupportsEvents.Should().BeTrue();
|
||||||
|
calendar.DefaultShowAs.Should().Be(CalendarItemShowAs.Free);
|
||||||
|
calendar.Order.Should().Be(2d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SynchronizeCalendarMetadataAsync_UpdatesServerBackedSettingsAndPreservesUserColorOverride()
|
||||||
|
{
|
||||||
|
var tempDirectory = CreateTempDirectory();
|
||||||
|
|
||||||
|
var serverInformation = new CustomServerInformation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
IncomingServer = "imap.example.com",
|
||||||
|
IncomingServerPort = "993",
|
||||||
|
IncomingServerUsername = "user@example.com",
|
||||||
|
IncomingServerPassword = "password",
|
||||||
|
OutgoingServer = "smtp.example.com",
|
||||||
|
OutgoingServerPort = "587",
|
||||||
|
OutgoingServerUsername = "user@example.com",
|
||||||
|
OutgoingServerPassword = "password",
|
||||||
|
MaxConcurrentClients = 5,
|
||||||
|
CalendarSupportMode = ImapCalendarSupportMode.CalDav
|
||||||
|
};
|
||||||
|
|
||||||
|
var account = new MailAccount
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "IMAP Test",
|
||||||
|
Address = "test@example.com",
|
||||||
|
ProviderType = MailProviderType.IMAP4,
|
||||||
|
IsCalendarAccessGranted = true,
|
||||||
|
ServerInformation = serverInformation
|
||||||
|
};
|
||||||
|
|
||||||
|
var localCalendar = new AccountCalendar
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = account.Id,
|
||||||
|
RemoteCalendarId = "https://calendar.example.com/calendars/work",
|
||||||
|
Name = "Local",
|
||||||
|
BackgroundColorHex = "#123456",
|
||||||
|
TextColorHex = "#FFFFFF",
|
||||||
|
IsBackgroundColorUserOverridden = true,
|
||||||
|
TimeZone = "UTC",
|
||||||
|
IsReadOnly = false,
|
||||||
|
DefaultShowAs = CalendarItemShowAs.Busy
|
||||||
|
};
|
||||||
|
|
||||||
|
var changeProcessor = new Mock<IImapChangeProcessor>();
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.GetAccountCalendarsAsync(account.Id))
|
||||||
|
.ReturnsAsync(new List<AccountCalendar> { localCalendar });
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.UpdateAccountCalendarAsync(It.IsAny<AccountCalendar>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.DeleteCalendarIcsForCalendarAsync(It.IsAny<Guid>(), It.IsAny<Guid>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.DeleteAccountCalendarAsync(It.IsAny<AccountCalendar>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.InsertAccountCalendarAsync(It.IsAny<AccountCalendar>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var synchronizer = CreateSynchronizer(tempDirectory, account, changeProcessor.Object);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await InvokePrivateAsync(
|
||||||
|
synchronizer,
|
||||||
|
"SynchronizeCalendarMetadataAsync",
|
||||||
|
new List<CalDavCalendar>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
RemoteCalendarId = localCalendar.RemoteCalendarId,
|
||||||
|
Name = "Remote",
|
||||||
|
TimeZone = "Europe/Warsaw",
|
||||||
|
BackgroundColorHex = "#ABCDEF",
|
||||||
|
IsReadOnly = true,
|
||||||
|
DefaultShowAs = CalendarItemShowAs.Free,
|
||||||
|
Order = 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
localCalendar.Name.Should().Be("Remote");
|
||||||
|
localCalendar.TimeZone.Should().Be("Europe/Warsaw");
|
||||||
|
localCalendar.IsReadOnly.Should().BeTrue();
|
||||||
|
localCalendar.DefaultShowAs.Should().Be(CalendarItemShowAs.Free);
|
||||||
|
localCalendar.IsPrimary.Should().BeTrue();
|
||||||
|
localCalendar.BackgroundColorHex.Should().Be("#123456");
|
||||||
|
localCalendar.TextColorHex.Should().Be(ColorHelpers.GetReadableTextColorHex("#123456"));
|
||||||
|
|
||||||
|
changeProcessor.Verify(x => x.UpdateAccountCalendarAsync(localCalendar), Times.Once);
|
||||||
|
changeProcessor.Verify(x => x.InsertAccountCalendarAsync(It.IsAny<AccountCalendar>()), Times.Never);
|
||||||
|
changeProcessor.Verify(x => x.DeleteAccountCalendarAsync(It.IsAny<AccountCalendar>()), Times.Never);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await synchronizer.KillSynchronizerAsync();
|
||||||
|
DeleteDirectory(tempDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CalDavCalendar> ParseCalendars(XDocument xml, Uri baseUri)
|
||||||
|
{
|
||||||
|
var parseMethod = typeof(CalDavClient).GetMethod(
|
||||||
|
"ParseCalendarCollection",
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
|
||||||
|
parseMethod.Should().NotBeNull();
|
||||||
|
|
||||||
|
var result = parseMethod!.Invoke(null, [xml, baseUri]);
|
||||||
|
return result.Should().BeOfType<List<CalDavCalendar>>().Subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImapSynchronizer CreateSynchronizer(string appDataFolder, MailAccount account, IImapChangeProcessor changeProcessor)
|
||||||
|
{
|
||||||
|
var applicationConfiguration = new Mock<IApplicationConfiguration>();
|
||||||
|
applicationConfiguration.SetupProperty(x => x.ApplicationDataFolderPath, appDataFolder);
|
||||||
|
applicationConfiguration.SetupProperty(x => x.PublisherSharedFolderPath, appDataFolder);
|
||||||
|
applicationConfiguration.SetupProperty(x => x.ApplicationTempFolderPath, appDataFolder);
|
||||||
|
applicationConfiguration.SetupGet(x => x.SentryDNS).Returns(string.Empty);
|
||||||
|
|
||||||
|
var unifiedSynchronizer = new UnifiedImapSynchronizer(
|
||||||
|
Mock.Of<IFolderService>(),
|
||||||
|
Mock.Of<IMailService>(),
|
||||||
|
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||||
|
|
||||||
|
return new ImapSynchronizer(
|
||||||
|
account,
|
||||||
|
changeProcessor,
|
||||||
|
applicationConfiguration.Object,
|
||||||
|
unifiedSynchronizer,
|
||||||
|
Mock.Of<IImapSynchronizerErrorHandlerFactory>(),
|
||||||
|
Mock.Of<ICalDavClient>(),
|
||||||
|
Mock.Of<IAutoDiscoveryService>(),
|
||||||
|
Mock.Of<ICalendarService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateTempDirectory()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), "wino-caldav-calendar-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteDirectory(string path)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
Directory.Delete(path, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task InvokePrivateAsync(object instance, string methodName, params object[] parameters)
|
||||||
|
{
|
||||||
|
var method = instance.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)
|
||||||
|
?? throw new InvalidOperationException($"Method '{methodName}' not found.");
|
||||||
|
|
||||||
|
var task = (Task)method.Invoke(instance, parameters)!;
|
||||||
|
await task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -389,6 +389,24 @@ public partial class PersonalizationPageViewModel : CoreBaseViewModel
|
|||||||
public bool HasReadReceiptTracking { get; } = false;
|
public bool HasReadReceiptTracking { get; } = false;
|
||||||
public bool IsReadReceiptAcknowledged { get; } = false;
|
public bool IsReadReceiptAcknowledged { get; } = false;
|
||||||
public string ReadReceiptDisplayText { get; } = string.Empty;
|
public string ReadReceiptDisplayText { get; } = string.Empty;
|
||||||
|
public IReadOnlyList<MailCategory> Categories { get; } =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Follow Up",
|
||||||
|
BackgroundColorHex = "#DCEBFF",
|
||||||
|
TextColorHex = "#0B5CAD"
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Planning",
|
||||||
|
BackgroundColorHex = "#DDF5D7",
|
||||||
|
TextColorHex = "#236A1E"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
public bool HasCategories => Categories.Count > 0;
|
||||||
public AccountContact SenderContact { get; } = null;
|
public AccountContact SenderContact { get; } = null;
|
||||||
event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged
|
event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1522,7 +1522,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId;
|
var remotePrimaryCalendarId = GetPrimaryCalDavCalendarId(remoteCalendars);
|
||||||
|
|
||||||
foreach (var localCalendar in localCalendars.ToList())
|
foreach (var localCalendar in localCalendars.ToList())
|
||||||
{
|
{
|
||||||
@@ -1545,6 +1545,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
|
|
||||||
if (existingLocal == null)
|
if (existingLocal == null)
|
||||||
{
|
{
|
||||||
|
var insertedCalendarColor = ResolveSynchronizedCalendarBackgroundColor(remoteCalendar.BackgroundColorHex, null, usedCalendarColors);
|
||||||
var newCalendar = new AccountCalendar
|
var newCalendar = new AccountCalendar
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -1552,10 +1553,12 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
RemoteCalendarId = remoteCalendar.RemoteCalendarId,
|
RemoteCalendarId = remoteCalendar.RemoteCalendarId,
|
||||||
Name = remoteCalendar.Name,
|
Name = remoteCalendar.Name,
|
||||||
IsPrimary = isPrimary,
|
IsPrimary = isPrimary,
|
||||||
|
IsReadOnly = remoteCalendar.IsReadOnly,
|
||||||
IsSynchronizationEnabled = true,
|
IsSynchronizationEnabled = true,
|
||||||
IsExtended = true,
|
IsExtended = true,
|
||||||
BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors),
|
DefaultShowAs = remoteCalendar.DefaultShowAs,
|
||||||
TimeZone = "UTC",
|
BackgroundColorHex = insertedCalendarColor,
|
||||||
|
TimeZone = remoteCalendar.TimeZone,
|
||||||
SynchronizationDeltaToken = string.Empty
|
SynchronizationDeltaToken = string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1565,10 +1568,15 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocal.BackgroundColorHex);
|
var resolvedColor = ResolveSynchronizedCalendarBackgroundColor(remoteCalendar.BackgroundColorHex, existingLocal, usedCalendarColors);
|
||||||
|
var resolvedTextColor = ColorHelpers.GetReadableTextColorHex(resolvedColor);
|
||||||
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(existingLocal.TimeZone, remoteCalendar.TimeZone, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| existingLocal.IsReadOnly != remoteCalendar.IsReadOnly
|
||||||
|
|| existingLocal.DefaultShowAs != remoteCalendar.DefaultShowAs
|
||||||
|| existingLocal.IsPrimary != isPrimary
|
|| existingLocal.IsPrimary != isPrimary
|
||||||
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase);
|
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !string.Equals(existingLocal.TextColorHex, resolvedTextColor, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (!shouldUpdate)
|
if (!shouldUpdate)
|
||||||
{
|
{
|
||||||
@@ -1577,14 +1585,54 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
existingLocal.Name = remoteCalendar.Name;
|
existingLocal.Name = remoteCalendar.Name;
|
||||||
|
existingLocal.TimeZone = remoteCalendar.TimeZone;
|
||||||
|
existingLocal.IsReadOnly = remoteCalendar.IsReadOnly;
|
||||||
|
existingLocal.DefaultShowAs = remoteCalendar.DefaultShowAs;
|
||||||
existingLocal.IsPrimary = isPrimary;
|
existingLocal.IsPrimary = isPrimary;
|
||||||
existingLocal.BackgroundColorHex = resolvedColor;
|
existingLocal.BackgroundColorHex = resolvedColor;
|
||||||
existingLocal.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocal.BackgroundColorHex);
|
existingLocal.TextColorHex = resolvedTextColor;
|
||||||
usedCalendarColors.Add(existingLocal.BackgroundColorHex);
|
usedCalendarColors.Add(existingLocal.BackgroundColorHex);
|
||||||
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
|
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetPrimaryCalDavCalendarId(IReadOnlyList<CalDavCalendar> remoteCalendars)
|
||||||
|
{
|
||||||
|
if (remoteCalendars == null || remoteCalendars.Count == 0)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
if (remoteCalendars.Any(calendar => calendar.Order.HasValue))
|
||||||
|
{
|
||||||
|
return remoteCalendars
|
||||||
|
.OrderBy(calendar => calendar.Order ?? double.MaxValue)
|
||||||
|
.ThenBy(calendar => calendar.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(calendar => calendar.RemoteCalendarId)
|
||||||
|
.FirstOrDefault() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteCalendars.First().RemoteCalendarId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveSynchronizedCalendarBackgroundColor(
|
||||||
|
string remoteBackgroundColor,
|
||||||
|
AccountCalendar accountCalendar,
|
||||||
|
ISet<string> usedCalendarColors = null)
|
||||||
|
{
|
||||||
|
if (accountCalendar?.IsBackgroundColorUserOverridden == true)
|
||||||
|
return accountCalendar.BackgroundColorHex;
|
||||||
|
|
||||||
|
var preferredColor = string.IsNullOrWhiteSpace(remoteBackgroundColor)
|
||||||
|
? accountCalendar?.BackgroundColorHex
|
||||||
|
: remoteBackgroundColor;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(remoteBackgroundColor) && usedCalendarColors != null)
|
||||||
|
return ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, preferredColor);
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(preferredColor)
|
||||||
|
? ColorHelpers.GenerateFlatColorHex()
|
||||||
|
: preferredColor;
|
||||||
|
}
|
||||||
|
|
||||||
private interface IImapCalendarOperationHandler
|
private interface IImapCalendarOperationHandler
|
||||||
{
|
{
|
||||||
bool RequiresConnectedClient { get; }
|
bool RequiresConnectedClient { get; }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using FluentAssertions;
|
|||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
using Wino.Mail.ViewModels.Collections;
|
using Wino.Mail.ViewModels.Collections;
|
||||||
using Wino.Mail.ViewModels.Data;
|
using Wino.Mail.ViewModels.Data;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -167,7 +168,7 @@ public class WinoMailCollectionTests
|
|||||||
groupItems.Add(item);
|
groupItems.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.Add(((DateTime)group.Key, groupItems));
|
groups.Add((((MailListGroupKey)group.Key).Value is DateTime keyDate ? keyDate : default, groupItems));
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.Should().NotBeEmpty();
|
groups.Should().NotBeEmpty();
|
||||||
@@ -188,6 +189,45 @@ public class WinoMailCollectionTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddRangeAsync_ShouldPlacePinnedItemsBeforeUnpinnedItems()
|
||||||
|
{
|
||||||
|
var sut = CreateCollection();
|
||||||
|
var olderPinned = CreateMailCopy(threadId: "pinned", creationDate: DateTime.UtcNow.AddDays(-3));
|
||||||
|
olderPinned.IsPinned = true;
|
||||||
|
|
||||||
|
var newerUnpinned = CreateMailCopy(threadId: "regular", creationDate: DateTime.UtcNow);
|
||||||
|
|
||||||
|
await sut.AddRangeAsync(
|
||||||
|
[
|
||||||
|
new MailItemViewModel(newerUnpinned),
|
||||||
|
new MailItemViewModel(olderPinned)
|
||||||
|
],
|
||||||
|
clearIdCache: true);
|
||||||
|
|
||||||
|
var firstItem = FlattenItems(sut).First().Should().BeOfType<MailItemViewModel>().Subject;
|
||||||
|
firstItem.MailCopy.UniqueId.Should().Be(olderPinned.UniqueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateMailCopy_ShouldMovePinnedItemToTop()
|
||||||
|
{
|
||||||
|
var sut = CreateCollection();
|
||||||
|
var older = CreateMailCopy(threadId: "older", creationDate: DateTime.UtcNow.AddDays(-2));
|
||||||
|
var newer = CreateMailCopy(threadId: "newer", creationDate: DateTime.UtcNow);
|
||||||
|
|
||||||
|
await sut.AddAsync(older);
|
||||||
|
await sut.AddAsync(newer);
|
||||||
|
|
||||||
|
var updatedOlder = CloneMailCopy(older);
|
||||||
|
updatedOlder.IsPinned = true;
|
||||||
|
|
||||||
|
await sut.UpdateMailCopy(updatedOlder, EntityUpdateSource.Server, MailCopyChangeFlags.IsPinned);
|
||||||
|
|
||||||
|
var firstItem = FlattenItems(sut).First().Should().BeOfType<MailItemViewModel>().Subject;
|
||||||
|
firstItem.MailCopy.UniqueId.Should().Be(older.UniqueId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateMailCopy_ShouldMergeExistingSingles_WhenThreadIdChangesToMatch()
|
public async Task UpdateMailCopy_ShouldMergeExistingSingles_WhenThreadIdChangesToMatch()
|
||||||
{
|
{
|
||||||
@@ -371,6 +411,7 @@ public class WinoMailCollectionTests
|
|||||||
Importance = source.Importance,
|
Importance = source.Importance,
|
||||||
IsRead = source.IsRead,
|
IsRead = source.IsRead,
|
||||||
IsFlagged = source.IsFlagged,
|
IsFlagged = source.IsFlagged,
|
||||||
|
IsPinned = source.IsPinned,
|
||||||
IsFocused = source.IsFocused,
|
IsFocused = source.IsFocused,
|
||||||
HasAttachments = source.HasAttachments,
|
HasAttachments = source.HasAttachments,
|
||||||
ItemType = source.ItemType,
|
ItemType = source.ItemType,
|
||||||
|
|||||||
@@ -59,6 +59,29 @@ public class MailItemViewModelUpdateTests
|
|||||||
nameof(MailItemViewModel.SortingName));
|
nameof(MailItemViewModel.SortingName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateFrom_ShouldNotifyPinnedState_WhenPinnedChanges()
|
||||||
|
{
|
||||||
|
var original = CreateMailCopy("thread-1", DateTime.UtcNow);
|
||||||
|
var updated = CloneMailCopy(original);
|
||||||
|
updated.IsPinned = true;
|
||||||
|
|
||||||
|
var sut = new MailItemViewModel(original);
|
||||||
|
var raisedProperties = new List<string>();
|
||||||
|
|
||||||
|
sut.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(e.PropertyName))
|
||||||
|
{
|
||||||
|
raisedProperties.Add(e.PropertyName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sut.UpdateFrom(updated);
|
||||||
|
|
||||||
|
raisedProperties.Should().Contain(nameof(MailItemViewModel.IsPinned));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateMailCopy_ShouldNotifyThreadOnlyForReadState_WhenReadStateChanges()
|
public async Task UpdateMailCopy_ShouldNotifyThreadOnlyForReadState_WhenReadStateChanges()
|
||||||
{
|
{
|
||||||
@@ -125,6 +148,7 @@ public class MailItemViewModelUpdateTests
|
|||||||
Importance = MailImportance.Normal,
|
Importance = MailImportance.Normal,
|
||||||
IsRead = false,
|
IsRead = false,
|
||||||
IsFlagged = false,
|
IsFlagged = false,
|
||||||
|
IsPinned = false,
|
||||||
IsFocused = false,
|
IsFocused = false,
|
||||||
HasAttachments = false,
|
HasAttachments = false,
|
||||||
ItemType = MailItemType.Mail,
|
ItemType = MailItemType.Mail,
|
||||||
@@ -151,6 +175,7 @@ public class MailItemViewModelUpdateTests
|
|||||||
Importance = source.Importance,
|
Importance = source.Importance,
|
||||||
IsRead = source.IsRead,
|
IsRead = source.IsRead,
|
||||||
IsFlagged = source.IsFlagged,
|
IsFlagged = source.IsFlagged,
|
||||||
|
IsPinned = source.IsPinned,
|
||||||
IsFocused = source.IsFocused,
|
IsFocused = source.IsFocused,
|
||||||
HasAttachments = source.HasAttachments,
|
HasAttachments = source.HasAttachments,
|
||||||
ItemType = source.ItemType,
|
ItemType = source.ItemType,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using Serilog;
|
|||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
using Wino.Mail.ViewModels.Data;
|
using Wino.Mail.ViewModels.Data;
|
||||||
using Wino.Messaging.Client.Mails;
|
using Wino.Messaging.Client.Mails;
|
||||||
using Wino.Messaging.UI;
|
using Wino.Messaging.UI;
|
||||||
@@ -139,10 +140,24 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
|
|
||||||
private object GetGroupingKey(IMailListItem mailItem)
|
private object GetGroupingKey(IMailListItem mailItem)
|
||||||
{
|
{
|
||||||
|
if (mailItem.IsPinned)
|
||||||
|
return MailListGroupKey.Pinned;
|
||||||
|
|
||||||
if (SortingType == SortingOptionType.ReceiveDate)
|
if (SortingType == SortingOptionType.ReceiveDate)
|
||||||
return mailItem.CreationDate.ToLocalTime().Date;
|
return new MailListGroupKey(false, mailItem.CreationDate.ToLocalTime().Date);
|
||||||
else
|
|
||||||
return mailItem.FromName;
|
return new MailListGroupKey(false, mailItem.FromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldReinsertForChanges(MailCopyChangeFlags changedProperties)
|
||||||
|
{
|
||||||
|
if ((changedProperties & (MailCopyChangeFlags.ThreadId | MailCopyChangeFlags.IsPinned)) != 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (SortingType == SortingOptionType.ReceiveDate)
|
||||||
|
return (changedProperties & MailCopyChangeFlags.CreationDate) != 0;
|
||||||
|
|
||||||
|
return (changedProperties & (MailCopyChangeFlags.FromName | MailCopyChangeFlags.FromAddress)) != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateUniqueIdHashes(IMailHashContainer itemContainer, bool isAdd)
|
private void UpdateUniqueIdHashes(IMailHashContainer itemContainer, bool isAdd)
|
||||||
@@ -608,7 +623,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if ((appliedChanges & MailCopyChangeFlags.ThreadId) != 0)
|
if (ShouldReinsertForChanges(appliedChanges))
|
||||||
{
|
{
|
||||||
await ReinsertUpdatedItemAsync(updatedItem, wasSelected, existingItem.IsBusy);
|
await ReinsertUpdatedItemAsync(updatedItem, wasSelected, existingItem.IsBusy);
|
||||||
return;
|
return;
|
||||||
@@ -993,6 +1008,16 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
if (updates.Count == 0)
|
if (updates.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (changedProperties == MailCopyChangeFlags.None || ShouldReinsertForChanges(changedProperties))
|
||||||
|
{
|
||||||
|
foreach (var update in updates)
|
||||||
|
{
|
||||||
|
await UpdateExistingItemAsync(update.ItemContainer, update.UpdatedMail, mailUpdateSource, changedProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ExecuteUIThread(() =>
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
foreach (var update in updates)
|
foreach (var update in updates)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
@@ -71,6 +73,8 @@ public partial class AccountContactViewModel : ObservableObject, IMailItemDispla
|
|||||||
public bool HasReadReceiptTracking => false;
|
public bool HasReadReceiptTracking => false;
|
||||||
public bool IsReadReceiptAcknowledged => false;
|
public bool IsReadReceiptAcknowledged => false;
|
||||||
public string ReadReceiptDisplayText => string.Empty;
|
public string ReadReceiptDisplayText => string.Empty;
|
||||||
|
public IReadOnlyList<MailCategory> Categories => [];
|
||||||
|
public bool HasCategories => false;
|
||||||
public AccountContact SenderContact => new()
|
public AccountContact SenderContact => new()
|
||||||
{
|
{
|
||||||
Address = Address,
|
Address = Address,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
@@ -17,6 +18,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(CreationDate))]
|
[NotifyPropertyChangedFor(nameof(CreationDate))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsFlagged))]
|
[NotifyPropertyChangedFor(nameof(IsFlagged))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsPinned))]
|
||||||
[NotifyPropertyChangedFor(nameof(FromName))]
|
[NotifyPropertyChangedFor(nameof(FromName))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsFocused))]
|
[NotifyPropertyChangedFor(nameof(IsFocused))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsRead))]
|
[NotifyPropertyChangedFor(nameof(IsRead))]
|
||||||
@@ -38,6 +40,8 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
[NotifyPropertyChangedFor(nameof(UniqueId))]
|
[NotifyPropertyChangedFor(nameof(UniqueId))]
|
||||||
[NotifyPropertyChangedFor(nameof(ContactPictureFileId))]
|
[NotifyPropertyChangedFor(nameof(ContactPictureFileId))]
|
||||||
[NotifyPropertyChangedFor(nameof(SenderContact))]
|
[NotifyPropertyChangedFor(nameof(SenderContact))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Categories))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasCategories))]
|
||||||
public partial MailCopy MailCopy { get; set; } = mailCopy;
|
public partial MailCopy MailCopy { get; set; } = mailCopy;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -82,6 +86,12 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
set => SetProperty(MailCopy.IsFlagged, value, MailCopy, (u, n) => u.IsFlagged = n);
|
set => SetProperty(MailCopy.IsFlagged, value, MailCopy, (u, n) => u.IsFlagged = n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsPinned
|
||||||
|
{
|
||||||
|
get => MailCopy.IsPinned;
|
||||||
|
set => SetProperty(MailCopy.IsPinned, value, MailCopy, (u, n) => u.IsPinned = n);
|
||||||
|
}
|
||||||
|
|
||||||
public string FromName
|
public string FromName
|
||||||
{
|
{
|
||||||
get => string.IsNullOrEmpty(MailCopy.FromName) ? MailCopy.FromAddress : MailCopy.FromName;
|
get => string.IsNullOrEmpty(MailCopy.FromName) ? MailCopy.FromAddress : MailCopy.FromName;
|
||||||
@@ -117,6 +127,10 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
_ => string.Empty
|
_ => string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public IReadOnlyList<MailCategory> Categories => MailCopy.Categories;
|
||||||
|
|
||||||
|
public bool HasCategories => Categories.Count > 0;
|
||||||
|
|
||||||
public string DraftId
|
public string DraftId
|
||||||
{
|
{
|
||||||
get => MailCopy.DraftId;
|
get => MailCopy.DraftId;
|
||||||
@@ -233,6 +247,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
{
|
{
|
||||||
nameof(CreationDate) or nameof(SortingDate) => MailCopyChangeFlags.CreationDate,
|
nameof(CreationDate) or nameof(SortingDate) => MailCopyChangeFlags.CreationDate,
|
||||||
nameof(IsFlagged) => MailCopyChangeFlags.IsFlagged,
|
nameof(IsFlagged) => MailCopyChangeFlags.IsFlagged,
|
||||||
|
nameof(IsPinned) => MailCopyChangeFlags.IsPinned,
|
||||||
nameof(FromName) or nameof(SortingName) => MailCopyChangeFlags.FromName,
|
nameof(FromName) or nameof(SortingName) => MailCopyChangeFlags.FromName,
|
||||||
nameof(IsFocused) => MailCopyChangeFlags.IsFocused,
|
nameof(IsFocused) => MailCopyChangeFlags.IsFocused,
|
||||||
nameof(IsRead) => MailCopyChangeFlags.IsRead,
|
nameof(IsRead) => MailCopyChangeFlags.IsRead,
|
||||||
@@ -254,6 +269,7 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
nameof(FolderId) => MailCopyChangeFlags.FolderId,
|
nameof(FolderId) => MailCopyChangeFlags.FolderId,
|
||||||
nameof(UniqueId) => MailCopyChangeFlags.UniqueId,
|
nameof(UniqueId) => MailCopyChangeFlags.UniqueId,
|
||||||
nameof(ContactPictureFileId) or nameof(SenderContact) => MailCopyChangeFlags.SenderContact,
|
nameof(ContactPictureFileId) or nameof(SenderContact) => MailCopyChangeFlags.SenderContact,
|
||||||
|
nameof(Categories) or nameof(HasCategories) => MailCopyChangeFlags.Categories,
|
||||||
_ => MailCopyChangeFlags.None
|
_ => MailCopyChangeFlags.None
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -293,12 +309,13 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
changedFlags |= SetIfChanged(MailCopy.Importance, source.Importance, value => MailCopy.Importance = value, MailCopyChangeFlags.Importance);
|
changedFlags |= SetIfChanged(MailCopy.Importance, source.Importance, value => MailCopy.Importance = value, MailCopyChangeFlags.Importance);
|
||||||
changedFlags |= SetIfChanged(MailCopy.IsRead, source.IsRead, value => MailCopy.IsRead = value, MailCopyChangeFlags.IsRead);
|
changedFlags |= SetIfChanged(MailCopy.IsRead, source.IsRead, value => MailCopy.IsRead = value, MailCopyChangeFlags.IsRead);
|
||||||
changedFlags |= SetIfChanged(MailCopy.IsFlagged, source.IsFlagged, value => MailCopy.IsFlagged = value, MailCopyChangeFlags.IsFlagged);
|
changedFlags |= SetIfChanged(MailCopy.IsFlagged, source.IsFlagged, value => MailCopy.IsFlagged = value, MailCopyChangeFlags.IsFlagged);
|
||||||
|
changedFlags |= SetIfChanged(MailCopy.IsPinned, source.IsPinned, value => MailCopy.IsPinned = value, MailCopyChangeFlags.IsPinned);
|
||||||
changedFlags |= SetIfChanged(MailCopy.IsFocused, source.IsFocused, value => MailCopy.IsFocused = value, MailCopyChangeFlags.IsFocused);
|
changedFlags |= SetIfChanged(MailCopy.IsFocused, source.IsFocused, value => MailCopy.IsFocused = value, MailCopyChangeFlags.IsFocused);
|
||||||
changedFlags |= SetIfChanged(MailCopy.FileId, source.FileId, value => MailCopy.FileId = value, MailCopyChangeFlags.FileId);
|
changedFlags |= SetIfChanged(MailCopy.FileId, source.FileId, value => MailCopy.FileId = value, MailCopyChangeFlags.FileId);
|
||||||
changedFlags |= SetIfChanged(MailCopy.ItemType, source.ItemType, value => MailCopy.ItemType = value, MailCopyChangeFlags.ItemType);
|
changedFlags |= SetIfChanged(MailCopy.ItemType, source.ItemType, value => MailCopy.ItemType = value, MailCopyChangeFlags.ItemType);
|
||||||
changedFlags |= SetIfChanged(MailCopy.SenderContact, source.SenderContact, value => MailCopy.SenderContact = value, MailCopyChangeFlags.SenderContact);
|
changedFlags |= SetIfChangedIfNotNull(MailCopy.SenderContact, source.SenderContact, value => MailCopy.SenderContact = value, MailCopyChangeFlags.SenderContact);
|
||||||
changedFlags |= SetIfChanged(MailCopy.AssignedAccount, source.AssignedAccount, value => MailCopy.AssignedAccount = value, MailCopyChangeFlags.AssignedAccount);
|
changedFlags |= SetIfChangedIfNotNull(MailCopy.AssignedAccount, source.AssignedAccount, value => MailCopy.AssignedAccount = value, MailCopyChangeFlags.AssignedAccount);
|
||||||
changedFlags |= SetIfChanged(MailCopy.AssignedFolder, source.AssignedFolder, value => MailCopy.AssignedFolder = value, MailCopyChangeFlags.AssignedFolder);
|
changedFlags |= SetIfChangedIfNotNull(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.UniqueId, source.UniqueId, value => MailCopy.UniqueId = value, MailCopyChangeFlags.UniqueId);
|
||||||
changedFlags |= SetIfChanged(MailCopy.IsReadReceiptRequested, source.IsReadReceiptRequested, value => MailCopy.IsReadReceiptRequested = value, MailCopyChangeFlags.ReadReceiptState);
|
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.ReadReceiptStatus, source.ReadReceiptStatus, value => MailCopy.ReadReceiptStatus = value, MailCopyChangeFlags.ReadReceiptState);
|
||||||
@@ -353,6 +370,14 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
return flag;
|
return flag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MailCopyChangeFlags SetIfChangedIfNotNull<T>(T currentValue, T newValue, Action<T> setter, MailCopyChangeFlags flag) where T : class
|
||||||
|
{
|
||||||
|
if (newValue == null)
|
||||||
|
return MailCopyChangeFlags.None;
|
||||||
|
|
||||||
|
return SetIfChanged(currentValue, newValue, setter, flag);
|
||||||
|
}
|
||||||
|
|
||||||
private void RaisePropertyChanges(MailCopyChangeFlags changedFlags)
|
private void RaisePropertyChanges(MailCopyChangeFlags changedFlags)
|
||||||
{
|
{
|
||||||
if (changedFlags == MailCopyChangeFlags.None)
|
if (changedFlags == MailCopyChangeFlags.None)
|
||||||
@@ -377,6 +402,9 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0)
|
if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0)
|
||||||
Queue(nameof(IsFlagged));
|
Queue(nameof(IsFlagged));
|
||||||
|
|
||||||
|
if ((changedFlags & MailCopyChangeFlags.IsPinned) != 0)
|
||||||
|
Queue(nameof(IsPinned));
|
||||||
|
|
||||||
if ((changedFlags & MailCopyChangeFlags.FromName) != 0)
|
if ((changedFlags & MailCopyChangeFlags.FromName) != 0)
|
||||||
{
|
{
|
||||||
Queue(nameof(FromName));
|
Queue(nameof(FromName));
|
||||||
@@ -454,9 +482,21 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
|
|||||||
Queue(nameof(SenderContact));
|
Queue(nameof(SenderContact));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((changedFlags & MailCopyChangeFlags.Categories) != 0)
|
||||||
|
{
|
||||||
|
Queue(nameof(Categories));
|
||||||
|
Queue(nameof(HasCategories));
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var changedProperty in changedProperties)
|
foreach (var changedProperty in changedProperties)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(changedProperty);
|
OnPropertyChanged(changedProperty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void UpdateCategories(IReadOnlyList<MailCategory> categories)
|
||||||
|
{
|
||||||
|
MailCopy.Categories = categories?.ToList() ?? [];
|
||||||
|
RaisePropertyChanges(MailCopyChangeFlags.Categories);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.ComponentModel;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
@@ -91,6 +92,11 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsFlagged => ThreadEmails.Any(e => e.IsFlagged);
|
public bool IsFlagged => ThreadEmails.Any(e => e.IsFlagged);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether any email in this thread is pinned.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPinned => ThreadEmails.Any(e => e.IsPinned);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets whether the latest email is focused
|
/// Gets whether the latest email is focused
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -106,6 +112,13 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
public bool IsReadReceiptAcknowledged => newestMailViewModel?.IsReadReceiptAcknowledged ?? false;
|
public bool IsReadReceiptAcknowledged => newestMailViewModel?.IsReadReceiptAcknowledged ?? false;
|
||||||
|
|
||||||
public string ReadReceiptDisplayText => newestMailViewModel?.ReadReceiptDisplayText ?? string.Empty;
|
public string ReadReceiptDisplayText => newestMailViewModel?.ReadReceiptDisplayText ?? string.Empty;
|
||||||
|
public IReadOnlyList<MailCategory> Categories => ThreadEmails
|
||||||
|
.SelectMany(a => a.Categories)
|
||||||
|
.GroupBy(a => a.Id)
|
||||||
|
.Select(a => a.First())
|
||||||
|
.OrderBy(a => a.Name)
|
||||||
|
.ToList();
|
||||||
|
public bool HasCategories => ThreadEmails.Any(a => a.HasCategories);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets whether any email in this thread is a draft
|
/// Gets whether any email in this thread is a draft
|
||||||
@@ -182,6 +195,7 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
[NotifyPropertyChangedFor(nameof(HasAttachments))]
|
[NotifyPropertyChangedFor(nameof(HasAttachments))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsCalendarEvent))]
|
[NotifyPropertyChangedFor(nameof(IsCalendarEvent))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsFlagged))]
|
[NotifyPropertyChangedFor(nameof(IsFlagged))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsPinned))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsFocused))]
|
[NotifyPropertyChangedFor(nameof(IsFocused))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsRead))]
|
[NotifyPropertyChangedFor(nameof(IsRead))]
|
||||||
[NotifyPropertyChangedFor(nameof(HasReadReceiptTracking))]
|
[NotifyPropertyChangedFor(nameof(HasReadReceiptTracking))]
|
||||||
@@ -200,6 +214,8 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
[NotifyPropertyChangedFor(nameof(UniqueId))]
|
[NotifyPropertyChangedFor(nameof(UniqueId))]
|
||||||
[NotifyPropertyChangedFor(nameof(ContactPictureFileId))]
|
[NotifyPropertyChangedFor(nameof(ContactPictureFileId))]
|
||||||
[NotifyPropertyChangedFor(nameof(SenderContact))]
|
[NotifyPropertyChangedFor(nameof(SenderContact))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Categories))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasCategories))]
|
||||||
public partial ObservableCollection<MailItemViewModel> ThreadEmails { get; set; } = [];
|
public partial ObservableCollection<MailItemViewModel> ThreadEmails { get; set; } = [];
|
||||||
|
|
||||||
private MailItemViewModel newestMailViewModel => _cachedNewestMailViewModel;
|
private MailItemViewModel newestMailViewModel => _cachedNewestMailViewModel;
|
||||||
@@ -461,6 +477,12 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
Queue(nameof(ContactPictureFileId));
|
Queue(nameof(ContactPictureFileId));
|
||||||
Queue(nameof(SenderContact));
|
Queue(nameof(SenderContact));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((changedFlags & MailCopyChangeFlags.Categories) != 0)
|
||||||
|
{
|
||||||
|
Queue(nameof(Categories));
|
||||||
|
Queue(nameof(HasCategories));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +495,9 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0 || changedFlags == MailCopyChangeFlags.All)
|
if ((changedFlags & MailCopyChangeFlags.IsFlagged) != 0 || changedFlags == MailCopyChangeFlags.All)
|
||||||
Queue(nameof(IsFlagged));
|
Queue(nameof(IsFlagged));
|
||||||
|
|
||||||
|
if ((changedFlags & MailCopyChangeFlags.IsPinned) != 0 || changedFlags == MailCopyChangeFlags.All)
|
||||||
|
Queue(nameof(IsPinned));
|
||||||
|
|
||||||
if ((changedFlags & MailCopyChangeFlags.IsRead) != 0 || changedFlags == MailCopyChangeFlags.All)
|
if ((changedFlags & MailCopyChangeFlags.IsRead) != 0 || changedFlags == MailCopyChangeFlags.All)
|
||||||
Queue(nameof(IsRead));
|
Queue(nameof(IsRead));
|
||||||
|
|
||||||
@@ -486,6 +511,12 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
if ((changedFlags & MailCopyChangeFlags.IsDraft) != 0 || changedFlags == MailCopyChangeFlags.All)
|
if ((changedFlags & MailCopyChangeFlags.IsDraft) != 0 || changedFlags == MailCopyChangeFlags.All)
|
||||||
Queue(nameof(IsDraft));
|
Queue(nameof(IsDraft));
|
||||||
|
|
||||||
|
if ((changedFlags & MailCopyChangeFlags.Categories) != 0 || changedFlags == MailCopyChangeFlags.All)
|
||||||
|
{
|
||||||
|
Queue(nameof(Categories));
|
||||||
|
Queue(nameof(HasCategories));
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var changedProperty in changedProperties)
|
foreach (var changedProperty in changedProperties)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(changedProperty);
|
OnPropertyChanged(changedProperty);
|
||||||
|
|||||||
@@ -613,6 +613,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
|||||||
var normalizedEmail = !string.IsNullOrWhiteSpace(EmailAddress) && !EmailAddress.Contains('@')
|
var normalizedEmail = !string.IsNullOrWhiteSpace(EmailAddress) && !EmailAddress.Contains('@')
|
||||||
? $"{EmailAddress}@icloud.com"
|
? $"{EmailAddress}@icloud.com"
|
||||||
: EmailAddress;
|
: EmailAddress;
|
||||||
|
var iCloudMailboxUsername = GetICloudMailboxUsername(normalizedEmail);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(accountCreationDialogResult?.SpecialImapProviderDetails?.SenderName))
|
if (!string.IsNullOrWhiteSpace(accountCreationDialogResult?.SpecialImapProviderDetails?.SenderName))
|
||||||
DisplayName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName;
|
DisplayName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName;
|
||||||
@@ -632,10 +633,10 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
|||||||
ApplySpecialProviderDefaults(
|
ApplySpecialProviderDefaults(
|
||||||
"imap.mail.me.com",
|
"imap.mail.me.com",
|
||||||
"993",
|
"993",
|
||||||
normalizedEmail,
|
iCloudMailboxUsername,
|
||||||
"smtp.mail.me.com",
|
"smtp.mail.me.com",
|
||||||
"587",
|
"587",
|
||||||
normalizedEmail,
|
iCloudMailboxUsername,
|
||||||
Password,
|
Password,
|
||||||
"https://caldav.icloud.com/",
|
"https://caldav.icloud.com/",
|
||||||
normalizedEmail,
|
normalizedEmail,
|
||||||
@@ -714,6 +715,19 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
|||||||
OutgoingServerPort = "587";
|
OutgoingServerPort = "587";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetICloudMailboxUsername(string emailAddress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(emailAddress))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var normalizedAddress = emailAddress.Trim();
|
||||||
|
var atIndex = normalizedAddress.IndexOf('@');
|
||||||
|
|
||||||
|
return atIndex > 0
|
||||||
|
? normalizedAddress[..atIndex]
|
||||||
|
: normalizedAddress;
|
||||||
|
}
|
||||||
|
|
||||||
private static string ReplaceIfEmptyOrMatchingPrevious(string currentValue, string previousValue, string replacementValue)
|
private static string ReplaceIfEmptyOrMatchingPrevious(string currentValue, string previousValue, string replacementValue)
|
||||||
{
|
{
|
||||||
var normalizedCurrentValue = currentValue?.Trim() ?? string.Empty;
|
var normalizedCurrentValue = currentValue?.Trim() ?? string.Empty;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -760,6 +761,17 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
await _winoRequestDelegator.ExecuteAsync(accountId, requests).ConfigureAwait(false);
|
await _winoRequestDelegator.ExecuteAsync(accountId, requests).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task ChangePinnedStatusAsync(IEnumerable<MailItemViewModel> targetItems, bool isPinned)
|
||||||
|
{
|
||||||
|
var uniqueIds = targetItems?
|
||||||
|
.Where(a => a?.MailCopy != null)
|
||||||
|
.Select(a => a.MailCopy.UniqueId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList() ?? [];
|
||||||
|
|
||||||
|
return _mailService.ChangePinnedStatusAsync(uniqueIds, isPinned);
|
||||||
|
}
|
||||||
|
|
||||||
private bool ShouldPreventItemAdd(MailCopy mailItem)
|
private bool ShouldPreventItemAdd(MailCopy mailItem)
|
||||||
{
|
{
|
||||||
bool condition = mailItem.IsRead
|
bool condition = mailItem.IsRead
|
||||||
@@ -1317,6 +1329,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
|
|
||||||
private async Task<List<MailItemViewModel>> PrepareMailViewModelsAsync(IEnumerable<MailCopy> mailItems, CancellationToken cancellationToken = default)
|
private async Task<List<MailItemViewModel>> PrepareMailViewModelsAsync(IEnumerable<MailCopy> mailItems, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
await PopulateMailCategoriesAsync(mailItems, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Run ViewModel creation on background thread to avoid blocking UI
|
// Run ViewModel creation on background thread to avoid blocking UI
|
||||||
return await Task.Run(() =>
|
return await Task.Run(() =>
|
||||||
{
|
{
|
||||||
@@ -1330,6 +1344,38 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
}, cancellationToken).ConfigureAwait(false);
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PopulateMailCategoriesAsync(IEnumerable<MailCopy> mailItems, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var mails = mailItems?.Where(a => a != null).ToList() ?? [];
|
||||||
|
if (mails.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var accountIdsByFolderId = ActiveFolder?.HandlingFolders?
|
||||||
|
.GroupBy(a => a.Id)
|
||||||
|
.ToDictionary(a => a.Key, a => a.First().MailAccountId) ?? new Dictionary<Guid, Guid>();
|
||||||
|
|
||||||
|
var mailsByAccount = mails
|
||||||
|
.GroupBy(mail => ResolveMailAccountId(mail, accountIdsByFolderId))
|
||||||
|
.Where(group => group.Key != Guid.Empty)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var groupedMails in mailsByAccount)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var categoriesByMail = await _mailCategoryService
|
||||||
|
.GetCategoriesByMailAsync(groupedMails.Key, groupedMails.Select(a => a.UniqueId))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var mail in groupedMails)
|
||||||
|
{
|
||||||
|
mail.Categories = categoriesByMail.TryGetValue(mail.UniqueId, out var categories)
|
||||||
|
? categories.ToList()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<HashSet<Guid>> GetPendingOperationUniqueIdsForActiveFolderAccountsAsync(CancellationToken cancellationToken = default)
|
private async Task<HashSet<Guid>> GetPendingOperationUniqueIdsForActiveFolderAccountsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var pendingOperationUniqueIds = new HashSet<Guid>();
|
var pendingOperationUniqueIds = new HashSet<Guid>();
|
||||||
@@ -1553,13 +1599,28 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var initialExistingIds = new ConcurrentDictionary<Guid, bool>(MailCollection.MailCopyIdHashSet);
|
||||||
|
var localPinnedItems = new List<MailCopy>();
|
||||||
|
|
||||||
|
if (!isDoingOnlineSearch)
|
||||||
|
{
|
||||||
|
var pinnedOptions = CreateInitializationOptions(SearchQuery, MailCollection.MailCopyIdHashSet);
|
||||||
|
localPinnedItems = await _mailService.FetchPinnedMailsAsync(pinnedOptions, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var pinnedItem in localPinnedItems)
|
||||||
|
{
|
||||||
|
initialExistingIds.TryAdd(pinnedItem.UniqueId, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var initializationOptions = CreateInitializationOptions(
|
var initializationOptions = CreateInitializationOptions(
|
||||||
isDoingOnlineSearch ? string.Empty : SearchQuery,
|
isDoingOnlineSearch ? string.Empty : SearchQuery,
|
||||||
MailCollection.MailCopyIdHashSet,
|
initialExistingIds,
|
||||||
onlineSearchItems,
|
onlineSearchItems,
|
||||||
isDoingOnlineSearch);
|
isDoingOnlineSearch);
|
||||||
|
|
||||||
items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false);
|
items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false);
|
||||||
|
items = localPinnedItems.Count > 0 ? [.. localPinnedItems, .. items] : items;
|
||||||
|
|
||||||
if (!listManipulationCancellationTokenSource.IsCancellationRequested)
|
if (!listManipulationCancellationTokenSource.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
|
|||||||
// Use the received date from MailCopy if available, otherwise fall back to the sent date from MIME message
|
// Use the received date from MailCopy if available, otherwise fall back to the sent date from MIME message
|
||||||
CreationDate = initializedMailItemViewModel?.MailCopy.CreationDate ?? message.Date.DateTime;
|
CreationDate = initializedMailItemViewModel?.MailCopy.CreationDate ?? message.Date.DateTime;
|
||||||
|
|
||||||
// Automatically disable images for Junk folder to prevent pixel tracking.
|
// Automatically block remote image loading for Junk folder to reduce pixel tracking.
|
||||||
// This can only work for selected mail item rendering, not for EML file rendering.
|
// This can only work for selected mail item rendering, not for EML file rendering.
|
||||||
if (initializedMailItemViewModel != null &&
|
if (initializedMailItemViewModel != null &&
|
||||||
initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk)
|
initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk)
|
||||||
@@ -604,6 +604,15 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
|
|||||||
if (initializedMailItemViewModel == null)
|
if (initializedMailItemViewModel == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var assignedFolder = initializedMailItemViewModel.MailCopy.AssignedFolder;
|
||||||
|
|
||||||
|
if (assignedFolder == null)
|
||||||
|
{
|
||||||
|
Log.Warning("Skipping folder-specific mail commands because AssignedFolder is missing for {MailUniqueId}",
|
||||||
|
initializedMailItemViewModel.MailCopy.UniqueId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Seperator));
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Seperator));
|
||||||
|
|
||||||
// You can't do these to draft items.
|
// You can't do these to draft items.
|
||||||
@@ -625,7 +634,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Archive - Unarchive
|
// Archive - Unarchive
|
||||||
if (initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Archive)
|
if (assignedFolder.SpecialFolderType == SpecialFolderType.Archive)
|
||||||
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.UnArchive));
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.UnArchive));
|
||||||
else
|
else
|
||||||
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Archive));
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.Archive));
|
||||||
@@ -647,10 +656,10 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
|
|||||||
else
|
else
|
||||||
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false));
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false));
|
||||||
|
|
||||||
if (initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Junk)
|
if (assignedFolder.SpecialFolderType == SpecialFolderType.Junk)
|
||||||
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsNotJunk, true, true));
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsNotJunk, true, true));
|
||||||
else if (!initializedMailItemViewModel.IsDraft &&
|
else if (!initializedMailItemViewModel.IsDraft &&
|
||||||
initializedMailItemViewModel.MailCopy.AssignedFolder.SpecialFolderType != SpecialFolderType.Sent)
|
assignedFolder.SpecialFolderType != SpecialFolderType.Sent)
|
||||||
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MoveToJunk, true, true));
|
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MoveToJunk, true, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.ComponentModel;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
@@ -187,6 +188,24 @@ public partial class MessageListPageViewModel : MailBaseViewModel
|
|||||||
public bool HasReadReceiptTracking => true;
|
public bool HasReadReceiptTracking => true;
|
||||||
public bool IsReadReceiptAcknowledged => false;
|
public bool IsReadReceiptAcknowledged => false;
|
||||||
public string ReadReceiptDisplayText => Translator.MailReceiptStatus_Requested;
|
public string ReadReceiptDisplayText => Translator.MailReceiptStatus_Requested;
|
||||||
|
public IReadOnlyList<MailCategory> Categories =>
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Urgent",
|
||||||
|
BackgroundColorHex = "#FFE1DE",
|
||||||
|
TextColorHex = "#A1260D"
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Client",
|
||||||
|
BackgroundColorHex = "#E4E8FF",
|
||||||
|
TextColorHex = "#4255C5"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
public bool HasCategories => Categories.Count > 0;
|
||||||
public AccountContact SenderContact => new()
|
public AccountContact SenderContact => new()
|
||||||
{
|
{
|
||||||
Address = "hi@bkaan.dev",
|
Address = "hi@bkaan.dev",
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel
|
|||||||
{
|
{
|
||||||
if (!CanProceed) return;
|
if (!CanProceed) return;
|
||||||
|
|
||||||
if (await _accountService.AccountAddressExistsAsync(EmailAddress).ConfigureAwait(false))
|
if (await _accountService.AccountAddressExistsAsync(EmailAddress))
|
||||||
{
|
{
|
||||||
await _dialogService.ShowMessageAsync(
|
await _dialogService.ShowMessageAsync(
|
||||||
Translator.DialogMessage_AccountAddressExistsMessage,
|
Translator.DialogMessage_AccountAddressExistsMessage,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
xmlns:domain="using:Wino.Core.Domain"
|
xmlns:domain="using:Wino.Core.Domain"
|
||||||
xmlns:enums="using:Wino.Core.Domain.Enums"
|
xmlns:enums="using:Wino.Core.Domain.Enums"
|
||||||
xmlns:helpers="using:Wino.Helpers"
|
xmlns:helpers="using:Wino.Helpers"
|
||||||
|
xmlns:mail="using:Wino.Core.Domain.Entities.Mail"
|
||||||
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
|
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
VerticalAlignment="Stretch"
|
VerticalAlignment="Stretch"
|
||||||
@@ -23,6 +24,28 @@
|
|||||||
Unloaded="OnUnloaded">
|
Unloaded="OnUnloaded">
|
||||||
|
|
||||||
<UserControl.Resources>
|
<UserControl.Resources>
|
||||||
|
<DataTemplate x:Key="DetailedMailCategoryTemplate" x:DataType="mail:MailCategory">
|
||||||
|
<Border
|
||||||
|
Margin="0,0,4,0"
|
||||||
|
Padding="6,2"
|
||||||
|
Background="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(BackgroundColorHex), Mode=OneWay}"
|
||||||
|
CornerRadius="8">
|
||||||
|
<TextBlock
|
||||||
|
Foreground="{x:Bind helpers:XamlHelpers.GetCategoryTextBrush(TextColorHex, BackgroundColorHex), Mode=OneWay}"
|
||||||
|
Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
|
Text="{x:Bind Name, Mode=OneWay}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
|
||||||
|
<DataTemplate x:Key="CompactMailCategoryTemplate" x:DataType="mail:MailCategory">
|
||||||
|
<Ellipse
|
||||||
|
Width="8"
|
||||||
|
Height="8"
|
||||||
|
Margin="0,0,4,0"
|
||||||
|
Fill="{x:Bind helpers:XamlHelpers.GetSolidColorBrushFromHex(BackgroundColorHex), Mode=OneWay}" />
|
||||||
|
</DataTemplate>
|
||||||
|
|
||||||
<Style
|
<Style
|
||||||
x:Key="HoverActionButtonStyle"
|
x:Key="HoverActionButtonStyle"
|
||||||
BasedOn="{StaticResource DefaultButtonStyle}"
|
BasedOn="{StaticResource DefaultButtonStyle}"
|
||||||
@@ -89,6 +112,7 @@
|
|||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Sender + IsDraft + Hover Buttons -->
|
<!-- Sender + IsDraft + Hover Buttons -->
|
||||||
@@ -206,6 +230,21 @@
|
|||||||
Text="{x:Bind helpers:XamlHelpers.GetMailItemDisplaySummaryForListing(MailItemInformation.IsDraft, MailItemInformation.CreationDate, Prefer24HourTimeFormat), Mode=OneWay}" />
|
Text="{x:Bind helpers:XamlHelpers.GetMailItemDisplaySummaryForListing(MailItemInformation.IsDraft, MailItemInformation.CreationDate, Prefer24HourTimeFormat), Mode=OneWay}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<ItemsControl
|
||||||
|
x:Name="DetailedCategoriesContainer"
|
||||||
|
Grid.Row="3"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
ItemTemplate="{StaticResource DetailedMailCategoryTemplate}"
|
||||||
|
ItemsSource="{x:Bind MailItemInformation.Categories, Mode=OneWay}"
|
||||||
|
Visibility="{x:Bind helpers:XamlHelpers.CountToVisibilityConverter(MailItemInformation.Categories.Count), Mode=OneWay}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
<!-- Message -->
|
<!-- Message -->
|
||||||
<Grid
|
<Grid
|
||||||
x:Name="PreviewTextContainerRoot"
|
x:Name="PreviewTextContainerRoot"
|
||||||
@@ -233,6 +272,18 @@
|
|||||||
Margin="4,4,1,4"
|
Margin="4,4,1,4"
|
||||||
Orientation="Horizontal"
|
Orientation="Horizontal"
|
||||||
Spacing="2">
|
Spacing="2">
|
||||||
|
<ItemsControl
|
||||||
|
x:Name="CompactCategoriesContainer"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
ItemTemplate="{StaticResource CompactMailCategoryTemplate}"
|
||||||
|
ItemsSource="{x:Bind MailItemInformation.Categories, Mode=OneWay}"
|
||||||
|
Visibility="Collapsed">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Margin="0,0,4,0"
|
Margin="0,0,4,0"
|
||||||
@@ -288,8 +339,13 @@
|
|||||||
<VisualStateGroup x:Name="SizingStates">
|
<VisualStateGroup x:Name="SizingStates">
|
||||||
<VisualState x:Name="Compact">
|
<VisualState x:Name="Compact">
|
||||||
<VisualState.Setters>
|
<VisualState.Setters>
|
||||||
<Setter Target="RootContainer.Height" Value="60" />
|
<Setter Target="RootContainer.Height" Value="64" />
|
||||||
<Setter Target="ContentGrid.Padding" Value="8,0" />
|
<Setter Target="ContentGrid.Padding" Value="8,6,8,6" />
|
||||||
|
<Setter Target="ContentStackpanel.VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Target="PreviewTextContainerRoot.Margin" Value="0" />
|
||||||
|
<Setter Target="IconsContainer.Margin" Value="4,0,1,0" />
|
||||||
|
<Setter Target="DetailedCategoriesContainer.Visibility" Value="Collapsed" />
|
||||||
|
<Setter Target="CompactCategoriesContainer.Visibility" Value="Visible" />
|
||||||
<Setter Target="PreviewTextContainer.Visibility" Value="Collapsed" />
|
<Setter Target="PreviewTextContainer.Visibility" Value="Collapsed" />
|
||||||
</VisualState.Setters>
|
</VisualState.Setters>
|
||||||
<VisualState.StateTriggers>
|
<VisualState.StateTriggers>
|
||||||
@@ -300,8 +356,13 @@
|
|||||||
<!-- Medium -->
|
<!-- Medium -->
|
||||||
<VisualState x:Name="Medium">
|
<VisualState x:Name="Medium">
|
||||||
<VisualState.Setters>
|
<VisualState.Setters>
|
||||||
<Setter Target="RootContainer.Height" Value="80" />
|
<Setter Target="RootContainer.Height" Value="96" />
|
||||||
<Setter Target="ContentGrid.Padding" Value="6,0" />
|
<Setter Target="ContentGrid.Padding" Value="6,8,6,6" />
|
||||||
|
<Setter Target="ContentStackpanel.VerticalAlignment" Value="Top" />
|
||||||
|
<Setter Target="DetailedCategoriesContainer.Margin" Value="0,2,0,0" />
|
||||||
|
<Setter Target="PreviewTextContainerRoot.Margin" Value="0,2,0,0" />
|
||||||
|
<Setter Target="IconsContainer.Margin" Value="4,2,1,0" />
|
||||||
|
<Setter Target="CompactCategoriesContainer.Visibility" Value="Collapsed" />
|
||||||
<Setter Target="PreviewTextContainer.Visibility" Value="Visible" />
|
<Setter Target="PreviewTextContainer.Visibility" Value="Visible" />
|
||||||
</VisualState.Setters>
|
</VisualState.Setters>
|
||||||
<VisualState.StateTriggers>
|
<VisualState.StateTriggers>
|
||||||
@@ -314,6 +375,11 @@
|
|||||||
<VisualState.Setters>
|
<VisualState.Setters>
|
||||||
<Setter Target="RootContainer.Height" Value="Auto" />
|
<Setter Target="RootContainer.Height" Value="Auto" />
|
||||||
<Setter Target="ContentGrid.Padding" Value="12,12,6,12" />
|
<Setter Target="ContentGrid.Padding" Value="12,12,6,12" />
|
||||||
|
<Setter Target="ContentStackpanel.VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Target="DetailedCategoriesContainer.Margin" Value="0,4,0,0" />
|
||||||
|
<Setter Target="PreviewTextContainerRoot.Margin" Value="0" />
|
||||||
|
<Setter Target="IconsContainer.Margin" Value="4,4,1,4" />
|
||||||
|
<Setter Target="CompactCategoriesContainer.Visibility" Value="Collapsed" />
|
||||||
<Setter Target="PreviewTextContainer.Visibility" Value="Visible" />
|
<Setter Target="PreviewTextContainer.Visibility" Value="Visible" />
|
||||||
</VisualState.Setters>
|
</VisualState.Setters>
|
||||||
<VisualState.StateTriggers>
|
<VisualState.StateTriggers>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ using Wino.Core.Domain;
|
|||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
using Wino.Mail.WinUI.Controls;
|
using Wino.Mail.WinUI.Controls;
|
||||||
|
|
||||||
namespace Wino.Helpers;
|
namespace Wino.Helpers;
|
||||||
@@ -151,6 +152,12 @@ public static class XamlHelpers
|
|||||||
public static Color GetWindowsColorFromHex(string hex) => hex.ToColor();
|
public static Color GetWindowsColorFromHex(string hex) => hex.ToColor();
|
||||||
|
|
||||||
public static SolidColorBrush GetSolidColorBrushFromHex(string colorHex) => string.IsNullOrEmpty(colorHex) ? new SolidColorBrush(Colors.Transparent) : new SolidColorBrush(colorHex.ToColor());
|
public static SolidColorBrush GetSolidColorBrushFromHex(string colorHex) => string.IsNullOrEmpty(colorHex) ? new SolidColorBrush(Colors.Transparent) : new SolidColorBrush(colorHex.ToColor());
|
||||||
|
public static SolidColorBrush GetCategoryTextBrush(string textColorHex, string backgroundColorHex)
|
||||||
|
=> !string.IsNullOrWhiteSpace(textColorHex)
|
||||||
|
? GetSolidColorBrushFromHex(textColorHex)
|
||||||
|
: string.IsNullOrWhiteSpace(backgroundColorHex)
|
||||||
|
? new SolidColorBrush(Colors.Black)
|
||||||
|
: GetReadableTextColor(backgroundColorHex);
|
||||||
public static FontWeight GetFontWeightBySyncState(bool isSyncing) => isSyncing ? FontWeights.SemiBold : FontWeights.Normal;
|
public static FontWeight GetFontWeightBySyncState(bool isSyncing) => isSyncing ? FontWeights.SemiBold : FontWeights.Normal;
|
||||||
|
|
||||||
public static Brush GetWizardStepBadgeBrush(bool isActive)
|
public static Brush GetWizardStepBadgeBrush(bool isActive)
|
||||||
@@ -184,6 +191,14 @@ public static class XamlHelpers
|
|||||||
}
|
}
|
||||||
public static string GetMailGroupDateString(object groupObject)
|
public static string GetMailGroupDateString(object groupObject)
|
||||||
{
|
{
|
||||||
|
if (groupObject is MailListGroupKey pinnedGroupKey)
|
||||||
|
{
|
||||||
|
if (pinnedGroupKey.IsPinned)
|
||||||
|
return Translator.FolderCustomization_SectionPinned;
|
||||||
|
|
||||||
|
groupObject = pinnedGroupKey.Value!;
|
||||||
|
}
|
||||||
|
|
||||||
if (groupObject is string stringObject)
|
if (groupObject is string stringObject)
|
||||||
return stringObject;
|
return stringObject;
|
||||||
|
|
||||||
|
|||||||
@@ -245,27 +245,36 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
|
|
||||||
// Default to all selected items.
|
// Default to all selected items.
|
||||||
targetItems = ViewModel.MailCollection.SelectedItems;
|
targetItems = ViewModel.MailCollection.SelectedItems;
|
||||||
|
var areAllPinned = targetItems.Any() && targetItems.All(item => item.MailCopy.IsPinned);
|
||||||
var availableActions = ViewModel.GetAvailableMailActions(targetItems);
|
var availableActions = ViewModel.GetAvailableMailActions(targetItems);
|
||||||
var (availableCategories, assignedCategoryIds) = await ViewModel.GetAvailableCategoriesAsync(targetItems);
|
var (availableCategories, assignedCategoryIds) = await ViewModel.GetAvailableCategoriesAsync(targetItems);
|
||||||
|
|
||||||
if (availableActions == null || !availableActions.Any()) return;
|
|
||||||
|
|
||||||
var clickedAction = await GetMailContextActionFromFlyoutAsync(
|
var clickedAction = await GetMailContextActionFromFlyoutAsync(
|
||||||
availableActions,
|
availableActions,
|
||||||
availableCategories,
|
availableCategories,
|
||||||
assignedCategoryIds,
|
assignedCategoryIds,
|
||||||
|
areAllPinned,
|
||||||
control,
|
control,
|
||||||
p.X,
|
p.X,
|
||||||
p.Y);
|
p.Y);
|
||||||
|
|
||||||
if (clickedAction == null) return;
|
if (clickedAction == null) return;
|
||||||
|
|
||||||
|
if (clickedAction.PinState.HasValue)
|
||||||
|
{
|
||||||
|
await ViewModel.ChangePinnedStatusAsync(targetItems, clickedAction.PinState.Value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (clickedAction.Category != null)
|
if (clickedAction.Category != null)
|
||||||
{
|
{
|
||||||
await ViewModel.ToggleCategoryAssignmentAsync(clickedAction.Category, targetItems, clickedAction.IsCategoryAssignedToAll);
|
await ViewModel.ToggleCategoryAssignmentAsync(clickedAction.Category, targetItems, clickedAction.IsCategoryAssignedToAll);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clickedAction.Operation == null)
|
||||||
|
return;
|
||||||
|
|
||||||
var prepRequest = new MailOperationPreperationRequest(clickedAction.Operation.Operation, targetItems.Select(a => a.MailCopy));
|
var prepRequest = new MailOperationPreperationRequest(clickedAction.Operation.Operation, targetItems.Select(a => a.MailCopy));
|
||||||
|
|
||||||
await ViewModel.ExecuteMailOperationAsync(prepRequest);
|
await ViewModel.ExecuteMailOperationAsync(prepRequest);
|
||||||
@@ -313,6 +322,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
IEnumerable<MailOperationMenuItem> availableActions,
|
IEnumerable<MailOperationMenuItem> availableActions,
|
||||||
IReadOnlyList<MailCategory> availableCategories,
|
IReadOnlyList<MailCategory> availableCategories,
|
||||||
IReadOnlyCollection<Guid> assignedCategoryIds,
|
IReadOnlyCollection<Guid> assignedCategoryIds,
|
||||||
|
bool areAllPinned,
|
||||||
UIElement showAtElement,
|
UIElement showAtElement,
|
||||||
double x,
|
double x,
|
||||||
double y)
|
double y)
|
||||||
@@ -320,7 +330,7 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
var source = new TaskCompletionSource<MailContextAction?>();
|
var source = new TaskCompletionSource<MailContextAction?>();
|
||||||
var flyout = new WinoMenuFlyout();
|
var flyout = new WinoMenuFlyout();
|
||||||
|
|
||||||
foreach (var action in availableActions)
|
foreach (var action in availableActions ?? [])
|
||||||
{
|
{
|
||||||
if (action.Operation == MailOperation.Seperator)
|
if (action.Operation == MailOperation.Seperator)
|
||||||
{
|
{
|
||||||
@@ -337,6 +347,27 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
flyout.Items.Add(menuFlyoutItem);
|
flyout.Items.Add(menuFlyoutItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (flyout.Items.Count > 0 && flyout.Items.LastOrDefault() is not MenuFlyoutSeparator)
|
||||||
|
{
|
||||||
|
flyout.Items.Add(new MenuFlyoutSeparator());
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinItem = new MenuFlyoutItem
|
||||||
|
{
|
||||||
|
Text = areAllPinned ? Translator.FolderOperation_Unpin : Translator.FolderOperation_Pin,
|
||||||
|
Icon = new WinoFontIcon { Icon = areAllPinned ? WinoIconGlyph.UnPin : WinoIconGlyph.Pin }
|
||||||
|
};
|
||||||
|
|
||||||
|
MenuFlyoutLanguageHelper.Apply(pinItem);
|
||||||
|
|
||||||
|
pinItem.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
source.TrySetResult(new MailContextAction(!areAllPinned));
|
||||||
|
flyout.Hide();
|
||||||
|
};
|
||||||
|
|
||||||
|
flyout.Items.Add(pinItem);
|
||||||
|
|
||||||
if (availableCategories?.Count > 0)
|
if (availableCategories?.Count > 0)
|
||||||
{
|
{
|
||||||
if (flyout.Items.LastOrDefault() is not MenuFlyoutSeparator)
|
if (flyout.Items.LastOrDefault() is not MenuFlyoutSeparator)
|
||||||
@@ -381,9 +412,13 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
return await source.Task;
|
return await source.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record MailContextAction(MailOperationMenuItem Operation, MailCategory Category = null, bool IsCategoryAssignedToAll = false)
|
private sealed record MailContextAction(MailOperationMenuItem? Operation = null, MailCategory? Category = null, bool IsCategoryAssignedToAll = false, bool? PinState = null)
|
||||||
{
|
{
|
||||||
public MailContextAction(MailCategory category, bool isCategoryAssignedToAll) : this(null, category, isCategoryAssignedToAll)
|
public MailContextAction(MailCategory category, bool isCategoryAssignedToAll) : this((MailOperationMenuItem?)null, category, isCategoryAssignedToAll)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public MailContextAction(bool pinState) : this((MailOperationMenuItem?)null, (MailCategory?)null, false, pinState)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,12 +44,19 @@ public sealed class CalDavClient : ICalDavClient
|
|||||||
var homeSetUri = await DiscoverCalendarHomeSetUriAsync(connectionSettings, principalUri, cancellationToken).ConfigureAwait(false);
|
var homeSetUri = await DiscoverCalendarHomeSetUriAsync(connectionSettings, principalUri, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var body = """
|
var body = """
|
||||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
|
||||||
<D:prop>
|
<D:prop>
|
||||||
<D:resourcetype />
|
<D:resourcetype />
|
||||||
<D:displayname />
|
<D:displayname />
|
||||||
|
<D:current-user-privilege-set />
|
||||||
<CS:getctag />
|
<CS:getctag />
|
||||||
<D:sync-token />
|
<D:sync-token />
|
||||||
|
<C:calendar-description />
|
||||||
|
<C:calendar-timezone />
|
||||||
|
<C:supported-calendar-component-set />
|
||||||
|
<C:schedule-calendar-transp />
|
||||||
|
<ICAL:calendar-color />
|
||||||
|
<ICAL:calendar-order />
|
||||||
</D:prop>
|
</D:prop>
|
||||||
</D:propfind>
|
</D:propfind>
|
||||||
""";
|
""";
|
||||||
@@ -344,10 +351,32 @@ public sealed class CalDavClient : ICalDavClient
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var displayName = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "displayname")?.Value ?? string.Empty;
|
var displayName = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "displayname")?.Value ?? string.Empty;
|
||||||
|
var description = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-description")?.Value ?? string.Empty;
|
||||||
var ctag = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "getctag")?.Value ?? string.Empty;
|
var ctag = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "getctag")?.Value ?? string.Empty;
|
||||||
var syncToken = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "sync-token")?.Value ?? string.Empty;
|
var syncToken = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "sync-token")?.Value ?? string.Empty;
|
||||||
|
var timeZone = ExtractCalendarTimeZoneId(
|
||||||
|
prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-timezone")?.Value);
|
||||||
|
var backgroundColor = NormalizeCalendarColor(
|
||||||
|
prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-color")?.Value);
|
||||||
|
var supportedComponents = prop
|
||||||
|
.Descendants()
|
||||||
|
.Where(e => e.Name.LocalName == "supported-calendar-component-set")
|
||||||
|
.Descendants()
|
||||||
|
.Where(e => e.Name.LocalName == "comp")
|
||||||
|
.Select(e => e.Attribute("name")?.Value?.Trim())
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.ToList();
|
||||||
|
var supportsEvents = supportedComponents.Count == 0 ||
|
||||||
|
supportedComponents.Contains("VEVENT", StringComparer.OrdinalIgnoreCase);
|
||||||
|
var isReadOnly = IsCalendarReadOnly(prop);
|
||||||
|
var defaultShowAs = GetDefaultShowAs(prop);
|
||||||
|
var calendarOrder = ParseCalendarOrder(
|
||||||
|
prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-order")?.Value);
|
||||||
var remoteUri = CreateAbsoluteUri(baseUri, href).ToString().TrimEnd('/');
|
var remoteUri = CreateAbsoluteUri(baseUri, href).ToString().TrimEnd('/');
|
||||||
|
|
||||||
|
if (!supportsEvents)
|
||||||
|
continue;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(displayName))
|
if (string.IsNullOrWhiteSpace(displayName))
|
||||||
{
|
{
|
||||||
displayName = WebUtility.UrlDecode(remoteUri.Split('/').LastOrDefault() ?? "Calendar");
|
displayName = WebUtility.UrlDecode(remoteUri.Split('/').LastOrDefault() ?? "Calendar");
|
||||||
@@ -357,8 +386,15 @@ public sealed class CalDavClient : ICalDavClient
|
|||||||
{
|
{
|
||||||
RemoteCalendarId = remoteUri,
|
RemoteCalendarId = remoteUri,
|
||||||
Name = displayName,
|
Name = displayName,
|
||||||
|
Description = description,
|
||||||
CTag = ctag,
|
CTag = ctag,
|
||||||
SyncToken = syncToken
|
SyncToken = syncToken,
|
||||||
|
TimeZone = timeZone,
|
||||||
|
BackgroundColorHex = backgroundColor,
|
||||||
|
IsReadOnly = isReadOnly,
|
||||||
|
SupportsEvents = supportsEvents,
|
||||||
|
DefaultShowAs = defaultShowAs,
|
||||||
|
Order = calendarOrder
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -820,6 +856,152 @@ public sealed class CalDavClient : ICalDavClient
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsCalendarReadOnly(XElement prop)
|
||||||
|
{
|
||||||
|
var privilegeSet = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "current-user-privilege-set");
|
||||||
|
if (privilegeSet == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var privilegeNames = privilegeSet
|
||||||
|
.Descendants()
|
||||||
|
.Where(e => e.Name.LocalName == "privilege")
|
||||||
|
.Descendants()
|
||||||
|
.Select(e => e.Name.LocalName)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return !privilegeNames.Contains("all")
|
||||||
|
&& !privilegeNames.Contains("write")
|
||||||
|
&& !privilegeNames.Contains("write-content");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CalendarItemShowAs GetDefaultShowAs(XElement prop)
|
||||||
|
{
|
||||||
|
var transparency = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "schedule-calendar-transp");
|
||||||
|
if (transparency?.Descendants().Any(e => e.Name.LocalName == "transparent") == true)
|
||||||
|
return CalendarItemShowAs.Free;
|
||||||
|
|
||||||
|
return CalendarItemShowAs.Busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? ParseCalendarOrder(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return double.TryParse(value.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var result)
|
||||||
|
? result
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractCalendarTimeZoneId(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var unfoldedValue = UnfoldIcsText(TrimCommonIndentation(value));
|
||||||
|
foreach (var rawLine in unfoldedValue.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var separatorIndex = line.IndexOf(':');
|
||||||
|
if (separatorIndex <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var propertyName = line[..separatorIndex];
|
||||||
|
var propertyValue = line[(separatorIndex + 1)..].Trim();
|
||||||
|
|
||||||
|
if (propertyName.StartsWith("TZID", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(propertyValue))
|
||||||
|
return propertyValue;
|
||||||
|
|
||||||
|
if (propertyName.StartsWith("X-WR-TIMEZONE", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(propertyValue))
|
||||||
|
return propertyValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UnfoldIcsText(string value)
|
||||||
|
{
|
||||||
|
var normalizedValue = value
|
||||||
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||||
|
.Replace('\r', '\n');
|
||||||
|
var unfoldedLines = new List<string>();
|
||||||
|
|
||||||
|
foreach (var rawLine in normalizedValue.Split('\n'))
|
||||||
|
{
|
||||||
|
if ((rawLine.StartsWith(' ') || rawLine.StartsWith('\t')) && unfoldedLines.Count > 0)
|
||||||
|
{
|
||||||
|
unfoldedLines[^1] += rawLine.TrimStart(' ', '\t');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unfoldedLines.Add(rawLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("\n", unfoldedLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TrimCommonIndentation(string value)
|
||||||
|
{
|
||||||
|
var normalizedValue = value
|
||||||
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||||
|
.Replace('\r', '\n');
|
||||||
|
var lines = normalizedValue.Split('\n');
|
||||||
|
var nonEmptyLines = lines
|
||||||
|
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (nonEmptyLines.Count == 0)
|
||||||
|
return normalizedValue;
|
||||||
|
|
||||||
|
var commonIndentation = nonEmptyLines
|
||||||
|
.Select(line => line.TakeWhile(ch => ch is ' ' or '\t').Count())
|
||||||
|
.Min();
|
||||||
|
|
||||||
|
if (commonIndentation <= 0)
|
||||||
|
return normalizedValue;
|
||||||
|
|
||||||
|
return string.Join(
|
||||||
|
"\n",
|
||||||
|
lines.Select(line =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return line.Length >= commonIndentation
|
||||||
|
? line[commonIndentation..]
|
||||||
|
: line.TrimStart(' ', '\t');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeCalendarColor(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var color = value.Trim();
|
||||||
|
if (color.StartsWith('#'))
|
||||||
|
{
|
||||||
|
color = color[1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.Length == 8)
|
||||||
|
{
|
||||||
|
color = color[..6];
|
||||||
|
}
|
||||||
|
else if (color.Length == 3)
|
||||||
|
{
|
||||||
|
color = string.Concat(color.Select(c => $"{c}{c}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.Length != 6 || !int.TryParse(color, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return $"#{color.ToUpperInvariant()}";
|
||||||
|
}
|
||||||
|
|
||||||
private static Uri CreateAbsoluteUri(Uri baseUri, string href)
|
private static Uri CreateAbsoluteUri(Uri baseUri, string href)
|
||||||
{
|
{
|
||||||
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
|
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ public class DatabaseService : IDatabaseService
|
|||||||
{
|
{
|
||||||
await EnsureKeyboardShortcutSchemaAsync().ConfigureAwait(false);
|
await EnsureKeyboardShortcutSchemaAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var mailCopyColumns = await Connection.GetTableInfoAsync(nameof(MailCopy)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!mailCopyColumns.Any(c => c.Name == nameof(MailCopy.IsPinned)))
|
||||||
|
{
|
||||||
|
await Connection
|
||||||
|
.ExecuteAsync($"ALTER TABLE {nameof(MailCopy)} ADD COLUMN {nameof(MailCopy.IsPinned)} INTEGER NOT NULL DEFAULT 0")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
var accountColumns = await Connection.GetTableInfoAsync(nameof(MailAccount)).ConfigureAwait(false);
|
var accountColumns = await Connection.GetTableInfoAsync(nameof(MailAccount)).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!accountColumns.Any(c => c.Name == nameof(MailAccount.CreatedAt)))
|
if (!accountColumns.Any(c => c.Name == nameof(MailAccount.CreatedAt)))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using HtmlAgilityPack;
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
namespace Wino.Services.Extensions;
|
namespace Wino.Services.Extensions;
|
||||||
@@ -8,16 +9,64 @@ namespace Wino.Services.Extensions;
|
|||||||
public static class HtmlAgilityPackExtensions
|
public static class HtmlAgilityPackExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears out the src attribute for all `img` and `v:fill` tags.
|
/// Clears passive remote image-loading hooks while preserving already-embedded inline images.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="document"></param>
|
|
||||||
public static void ClearImages(this HtmlDocument document)
|
public static void ClearImages(this HtmlDocument document)
|
||||||
{
|
{
|
||||||
if (document.DocumentNode.InnerHtml.Contains("<img"))
|
if (document?.DocumentNode == null)
|
||||||
{
|
{
|
||||||
foreach (var eachNode in document.DocumentNode.SelectNodes("//img"))
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var eachNode in document.DocumentNode.Descendants().ToList())
|
||||||
|
{
|
||||||
|
ClearRemoteImageAttribute(eachNode, "src");
|
||||||
|
ClearRemoteImageAttribute(eachNode, "background");
|
||||||
|
ClearRemoteImageAttribute(eachNode, "poster");
|
||||||
|
ClearRemoteImageAttribute(eachNode, "data");
|
||||||
|
|
||||||
|
if (eachNode.Attributes.Contains("srcset"))
|
||||||
{
|
{
|
||||||
eachNode.Attributes.Remove("src");
|
eachNode.Attributes.Remove("srcset");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eachNode.Attributes.Contains("imagesrcset"))
|
||||||
|
{
|
||||||
|
eachNode.Attributes.Remove("imagesrcset");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eachNode.Attributes.Contains("style"))
|
||||||
|
{
|
||||||
|
var sanitizedStyle = SanitizeCss(eachNode.GetAttributeValue("style", string.Empty));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(sanitizedStyle))
|
||||||
|
{
|
||||||
|
eachNode.Attributes.Remove("style");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
eachNode.SetAttributeValue("style", sanitizedStyle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsSvgImageReferenceElement(eachNode))
|
||||||
|
{
|
||||||
|
ClearRemoteImageAttribute(eachNode, "href");
|
||||||
|
ClearRemoteImageAttribute(eachNode, "xlink:href");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var styleNode in document.DocumentNode.Descendants("style").ToList())
|
||||||
|
{
|
||||||
|
var sanitizedCss = SanitizeCss(styleNode.InnerHtml);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(sanitizedCss))
|
||||||
|
{
|
||||||
|
styleNode.Remove();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
styleNode.InnerHtml = sanitizedCss;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,4 +165,47 @@ public static class HtmlAgilityPackExtensions
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ClearRemoteImageAttribute(HtmlNode node, string attributeName)
|
||||||
|
{
|
||||||
|
var value = node.GetAttributeValue(attributeName, null);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsEmbeddedImageSource(value))
|
||||||
|
{
|
||||||
|
node.Attributes.Remove(attributeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmbeddedImageSource(string value)
|
||||||
|
{
|
||||||
|
var trimmed = value.Trim().Trim('"', '\'');
|
||||||
|
|
||||||
|
return trimmed.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| trimmed.StartsWith("cid:", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| trimmed.StartsWith('#');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSvgImageReferenceElement(HtmlNode node)
|
||||||
|
=> node.Name.Equals("image", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| node.Name.Equals("feImage", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| node.Name.Equals("use", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string SanitizeCss(string css)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(css))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitizedCss = Regex.Replace(css, @"(?is)url\s*\([^)]*\)", "none");
|
||||||
|
sanitizedCss = Regex.Replace(sanitizedCss, @"(?is)image-set\s*\([^)]*\)", "none");
|
||||||
|
sanitizedCss = Regex.Replace(sanitizedCss, @"(?is)@import\s+[^;]+;?", string.Empty);
|
||||||
|
|
||||||
|
return sanitizedCss.Trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,39 @@ public class MailCategoryService : BaseDatabaseService, IMailCategoryService
|
|||||||
[accountId, .. uniqueIds.Cast<object>()]).ConfigureAwait(false);
|
[accountId, .. uniqueIds.Cast<object>()]).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<MailCategory>>> GetCategoriesByMailAsync(Guid accountId, IEnumerable<Guid> mailCopyUniqueIds)
|
||||||
|
{
|
||||||
|
var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? [];
|
||||||
|
if (uniqueIds.Count == 0)
|
||||||
|
return new Dictionary<Guid, IReadOnlyList<MailCategory>>();
|
||||||
|
|
||||||
|
var placeholders = string.Join(",", uniqueIds.Select(_ => "?"));
|
||||||
|
var sql =
|
||||||
|
$"SELECT {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCopyUniqueId)} as {nameof(MailCategoryRow.MailCopyUniqueId)}, " +
|
||||||
|
$"{nameof(MailCategory)}.{nameof(MailCategory.Id)} as {nameof(MailCategoryRow.Id)}, " +
|
||||||
|
$"{nameof(MailCategory)}.{nameof(MailCategory.MailAccountId)} as {nameof(MailCategoryRow.MailAccountId)}, " +
|
||||||
|
$"{nameof(MailCategory)}.{nameof(MailCategory.RemoteId)} as {nameof(MailCategoryRow.RemoteId)}, " +
|
||||||
|
$"{nameof(MailCategory)}.{nameof(MailCategory.Name)} as {nameof(MailCategoryRow.Name)}, " +
|
||||||
|
$"{nameof(MailCategory)}.{nameof(MailCategory.IsFavorite)} as {nameof(MailCategoryRow.IsFavorite)}, " +
|
||||||
|
$"{nameof(MailCategory)}.{nameof(MailCategory.BackgroundColorHex)} as {nameof(MailCategoryRow.BackgroundColorHex)}, " +
|
||||||
|
$"{nameof(MailCategory)}.{nameof(MailCategory.TextColorHex)} as {nameof(MailCategoryRow.TextColorHex)}, " +
|
||||||
|
$"{nameof(MailCategory)}.{nameof(MailCategory.Source)} as {nameof(MailCategoryRow.Source)} " +
|
||||||
|
$"FROM {nameof(MailCategory)} " +
|
||||||
|
$"INNER JOIN {nameof(MailCategoryAssignment)} ON {nameof(MailCategory)}.{nameof(MailCategory.Id)} = {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCategoryId)} " +
|
||||||
|
$"WHERE {nameof(MailCategory)}.{nameof(MailCategory.MailAccountId)} = ? AND {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCopyUniqueId)} IN ({placeholders}) " +
|
||||||
|
$"ORDER BY {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCopyUniqueId)}, {nameof(MailCategory)}.{nameof(MailCategory.Name)} COLLATE NOCASE";
|
||||||
|
|
||||||
|
var rows = await Connection.QueryAsync<MailCategoryRow>(
|
||||||
|
sql,
|
||||||
|
[accountId, .. uniqueIds.Cast<object>()]).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.GroupBy(a => a.MailCopyUniqueId)
|
||||||
|
.ToDictionary(
|
||||||
|
a => a.Key,
|
||||||
|
a => (IReadOnlyList<MailCategory>)a.Select(static row => row.ToMailCategory()).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<Guid>> GetAssignedCategoryIdsForAllAsync(IEnumerable<Guid> mailCopyUniqueIds)
|
public async Task<List<Guid>> GetAssignedCategoryIdsForAllAsync(IEnumerable<Guid> mailCopyUniqueIds)
|
||||||
{
|
{
|
||||||
var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? [];
|
var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? [];
|
||||||
@@ -355,4 +388,21 @@ public class MailCategoryService : BaseDatabaseService, IMailCategoryService
|
|||||||
|
|
||||||
private static string NormalizeCategoryName(string name)
|
private static string NormalizeCategoryName(string name)
|
||||||
=> name?.Trim() ?? string.Empty;
|
=> name?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
private sealed class MailCategoryRow : MailCategory
|
||||||
|
{
|
||||||
|
public Guid MailCopyUniqueId { get; set; }
|
||||||
|
|
||||||
|
public MailCategory ToMailCategory() => new()
|
||||||
|
{
|
||||||
|
Id = Id,
|
||||||
|
MailAccountId = MailAccountId,
|
||||||
|
RemoteId = RemoteId,
|
||||||
|
Name = Name,
|
||||||
|
IsFavorite = IsFavorite,
|
||||||
|
BackgroundColorHex = BackgroundColorHex,
|
||||||
|
TextColorHex = TextColorHex,
|
||||||
|
Source = Source
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-19
@@ -158,7 +158,7 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
return await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
|
return await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options)
|
private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options, bool pinnedOnly = false)
|
||||||
{
|
{
|
||||||
var sql = new StringBuilder();
|
var sql = new StringBuilder();
|
||||||
sql.Append(options.IsCategoryView
|
sql.Append(options.IsCategoryView
|
||||||
@@ -194,6 +194,11 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pinnedOnly)
|
||||||
|
{
|
||||||
|
whereClauses.Add("MailCopy.IsPinned = 1");
|
||||||
|
}
|
||||||
|
|
||||||
// Focused filter
|
// Focused filter
|
||||||
if (options.IsFocusedOnly != null)
|
if (options.IsFocusedOnly != null)
|
||||||
{
|
{
|
||||||
@@ -227,23 +232,26 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
if (options.SortingOptionType == SortingOptionType.ReceiveDate)
|
if (options.SortingOptionType == SortingOptionType.ReceiveDate)
|
||||||
sql.Append(" ORDER BY CreationDate DESC");
|
sql.Append(" ORDER BY IsPinned DESC, CreationDate DESC");
|
||||||
else if (options.SortingOptionType == SortingOptionType.Sender)
|
else if (options.SortingOptionType == SortingOptionType.Sender)
|
||||||
sql.Append(" ORDER BY FromName ASC");
|
sql.Append(" ORDER BY IsPinned DESC, FromName ASC, CreationDate DESC");
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
var limit = options.Take > 0 ? options.Take : ItemLoadCount;
|
if (!pinnedOnly)
|
||||||
sql.Append($" LIMIT {limit}");
|
|
||||||
|
|
||||||
if (options.Skip > 0)
|
|
||||||
{
|
{
|
||||||
sql.Append($" OFFSET {options.Skip}");
|
var limit = options.Take > 0 ? options.Take : ItemLoadCount;
|
||||||
|
sql.Append($" LIMIT {limit}");
|
||||||
|
|
||||||
|
if (options.Skip > 0)
|
||||||
|
{
|
||||||
|
sql.Append($" OFFSET {options.Skip}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (sql.ToString(), parameters.ToArray());
|
return (sql.ToString(), parameters.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<MailCopy> ApplyOptionsToPreFetchedMails(MailListInitializationOptions options)
|
private static List<MailCopy> ApplyOptionsToPreFetchedMails(MailListInitializationOptions options, bool pinnedOnly = false)
|
||||||
{
|
{
|
||||||
var allowedFolderIds = options.Folders.Select(f => f.Id).ToHashSet();
|
var allowedFolderIds = options.Folders.Select(f => f.Id).ToHashSet();
|
||||||
var accountIdsByFolderId = options.Folders
|
var accountIdsByFolderId = options.Folders
|
||||||
@@ -287,6 +295,11 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
query = query.Where(m => !options.ExistingUniqueIds.ContainsKey(m.UniqueId));
|
query = query.Where(m => !options.ExistingUniqueIds.ContainsKey(m.UniqueId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pinnedOnly)
|
||||||
|
{
|
||||||
|
query = query.Where(m => m.IsPinned);
|
||||||
|
}
|
||||||
|
|
||||||
query = options.DeduplicateByServerId
|
query = options.DeduplicateByServerId
|
||||||
? query
|
? query
|
||||||
.GroupBy(m => (ResolveMailAccountId(m, accountIdsByFolderId), ResolveServerMailId(m)))
|
.GroupBy(m => (ResolveMailAccountId(m, accountIdsByFolderId), ResolveServerMailId(m)))
|
||||||
@@ -302,16 +315,21 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
|
|
||||||
query = options.SortingOptionType switch
|
query = options.SortingOptionType switch
|
||||||
{
|
{
|
||||||
SortingOptionType.Sender => query.OrderBy(m => m.FromName).ThenByDescending(m => m.CreationDate),
|
SortingOptionType.Sender => query
|
||||||
_ => query.OrderByDescending(m => m.CreationDate)
|
.OrderByDescending(m => m.IsPinned)
|
||||||
|
.ThenBy(m => m.FromName)
|
||||||
|
.ThenByDescending(m => m.CreationDate),
|
||||||
|
_ => query
|
||||||
|
.OrderByDescending(m => m.IsPinned)
|
||||||
|
.ThenByDescending(m => m.CreationDate)
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.Skip > 0)
|
if (!pinnedOnly && options.Skip > 0)
|
||||||
{
|
{
|
||||||
query = query.Skip(options.Skip);
|
query = query.Skip(options.Skip);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.Take > 0)
|
if (!pinnedOnly && options.Take > 0)
|
||||||
{
|
{
|
||||||
query = query.Take(options.Take);
|
query = query.Take(options.Take);
|
||||||
}
|
}
|
||||||
@@ -333,17 +351,23 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
private static string ResolveServerMailId(MailCopy mail)
|
private static string ResolveServerMailId(MailCopy mail)
|
||||||
=> string.IsNullOrWhiteSpace(mail?.Id) ? mail?.UniqueId.ToString("N") ?? string.Empty : mail.Id;
|
=> string.IsNullOrWhiteSpace(mail?.Id) ? mail?.UniqueId.ToString("N") ?? string.Empty : mail.Id;
|
||||||
|
|
||||||
public async Task<List<MailCopy>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
|
public Task<List<MailCopy>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
|
||||||
|
=> FetchMailsInternalAsync(options, pinnedOnly: false, cancellationToken);
|
||||||
|
|
||||||
|
public Task<List<MailCopy>> FetchPinnedMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
|
||||||
|
=> FetchMailsInternalAsync(options, pinnedOnly: true, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<List<MailCopy>> FetchMailsInternalAsync(MailListInitializationOptions options, bool pinnedOnly, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
List<MailCopy> mails;
|
List<MailCopy> mails;
|
||||||
|
|
||||||
if (options.PreFetchMailCopies != null && !options.IsCategoryView)
|
if (options.PreFetchMailCopies != null && !options.IsCategoryView)
|
||||||
{
|
{
|
||||||
mails = ApplyOptionsToPreFetchedMails(options);
|
mails = ApplyOptionsToPreFetchedMails(options, pinnedOnly);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var (query, parameters) = BuildMailFetchQuery(options);
|
var (query, parameters) = BuildMailFetchQuery(options, pinnedOnly);
|
||||||
mails = await Connection.QueryAsync<MailCopy>(query, parameters);
|
mails = await Connection.QueryAsync<MailCopy>(query, parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +759,8 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
|
|
||||||
await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
|
await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
|
||||||
|
|
||||||
ReportUIChange(new MailAddedMessage(mailCopy, EntityUpdateSource.Server));
|
var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
|
||||||
|
ReportUIChange(new MailAddedMessage(hydratedMailCopy, EntityUpdateSource.Server));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateMailAsync(MailCopy mailCopy)
|
public async Task UpdateMailAsync(MailCopy mailCopy)
|
||||||
@@ -749,9 +774,20 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
|
|
||||||
_logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
|
_logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
|
||||||
|
|
||||||
|
var existingMailCopy = mailCopy.UniqueId != Guid.Empty
|
||||||
|
? await Connection.FindAsync<MailCopy>(mailCopy.UniqueId).ConfigureAwait(false)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (existingMailCopy != null)
|
||||||
|
{
|
||||||
|
// Pinning is managed locally for now, so server refreshes should not clear it.
|
||||||
|
mailCopy.IsPinned = existingMailCopy.IsPinned;
|
||||||
|
}
|
||||||
|
|
||||||
await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
|
await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
|
||||||
|
|
||||||
ReportUIChange(new MailUpdatedMessage(mailCopy, EntityUpdateSource.Server));
|
var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
|
||||||
|
ReportUIChange(new MailUpdatedMessage(hydratedMailCopy, EntityUpdateSource.Server));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile)
|
private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile)
|
||||||
@@ -807,12 +843,23 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
WeakReferenceMessenger.Default.Send(new BulkMailReadStatusChanged(readMailUniqueIds));
|
WeakReferenceMessenger.Default.Send(new BulkMailReadStatusChanged(readMailUniqueIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hydratedUpdatesByUniqueId = (await HydrateMailCopiesAsync(
|
||||||
|
pendingUpdates
|
||||||
|
.Where(x => x.MailCopy != null)
|
||||||
|
.Select(x => x.MailCopy)
|
||||||
|
.GroupBy(x => x.UniqueId)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToList())
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
.Where(x => x != null)
|
||||||
|
.ToDictionary(x => x.UniqueId);
|
||||||
|
|
||||||
foreach (var updateGroup in pendingUpdates
|
foreach (var updateGroup in pendingUpdates
|
||||||
.Where(x => x.MailCopy != null)
|
.Where(x => x.MailCopy != null)
|
||||||
.GroupBy(x => x.ChangedProperties))
|
.GroupBy(x => x.ChangedProperties))
|
||||||
{
|
{
|
||||||
var updatedMails = updateGroup
|
var updatedMails = updateGroup
|
||||||
.Select(x => x.MailCopy)
|
.Select(x => hydratedUpdatesByUniqueId.GetValueOrDefault(x.MailCopy.UniqueId, x.MailCopy))
|
||||||
.Where(x => x != null)
|
.Where(x => x != null)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -875,6 +922,41 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
return MailCopyChangeFlags.IsFlagged;
|
return MailCopyChangeFlags.IsFlagged;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public async Task ChangePinnedStatusAsync(IEnumerable<Guid> uniqueMailIds, bool isPinned)
|
||||||
|
{
|
||||||
|
var distinctUniqueIds = uniqueMailIds?
|
||||||
|
.Where(id => id != Guid.Empty)
|
||||||
|
.Distinct()
|
||||||
|
.ToList() ?? [];
|
||||||
|
|
||||||
|
if (distinctUniqueIds.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var placeholders = string.Join(",", distinctUniqueIds.Select(_ => "?"));
|
||||||
|
var mailCopies = await Connection
|
||||||
|
.QueryAsync<MailCopy>($"SELECT * FROM MailCopy WHERE UniqueId IN ({placeholders})", distinctUniqueIds.Cast<object>().ToArray())
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (mailCopies.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.Warning("Changing pin status failed because there are no matching copies for {MailCopyCount} unique ids.", distinctUniqueIds.Count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingUpdates = new List<(MailCopy MailCopy, MailCopyChangeFlags ChangedProperties)>();
|
||||||
|
|
||||||
|
foreach (var mailCopy in mailCopies)
|
||||||
|
{
|
||||||
|
if (mailCopy.IsPinned == isPinned)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
mailCopy.IsPinned = isPinned;
|
||||||
|
pendingUpdates.Add((mailCopy, MailCopyChangeFlags.IsPinned));
|
||||||
|
}
|
||||||
|
|
||||||
|
await PersistMailCopyUpdatesAsync(pendingUpdates).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates)
|
public async Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates)
|
||||||
{
|
{
|
||||||
var updateLookup = new Dictionary<string, MailCopyStateUpdate>(StringComparer.Ordinal);
|
var updateLookup = new Dictionary<string, MailCopyStateUpdate>(StringComparer.Ordinal);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public class SpecialImapProviderConfigResolver : ISpecialImapProviderConfigResol
|
|||||||
|
|
||||||
if (details.SpecialImapProvider == SpecialImapProvider.iCloud)
|
if (details.SpecialImapProvider == SpecialImapProvider.iCloud)
|
||||||
{
|
{
|
||||||
|
var iCloudMailboxUsername = GetICloudMailboxUsername(details.Address);
|
||||||
|
|
||||||
resolvedConfig = new CustomServerInformation()
|
resolvedConfig = new CustomServerInformation()
|
||||||
{
|
{
|
||||||
IncomingServer = "imap.mail.me.com",
|
IncomingServer = "imap.mail.me.com",
|
||||||
@@ -29,9 +31,9 @@ public class SpecialImapProviderConfigResolver : ISpecialImapProviderConfigResol
|
|||||||
CalDavServiceUrl = "https://caldav.icloud.com/"
|
CalDavServiceUrl = "https://caldav.icloud.com/"
|
||||||
};
|
};
|
||||||
|
|
||||||
// iCloud takes username before the @icloud part for incoming, but full address as outgoing.
|
// iCloud IMAP/SMTP authentication uses only the local-part mailbox username.
|
||||||
resolvedConfig.IncomingServerUsername = details.Address.Split('@')[0];
|
resolvedConfig.IncomingServerUsername = iCloudMailboxUsername;
|
||||||
resolvedConfig.OutgoingServerUsername = details.Address;
|
resolvedConfig.OutgoingServerUsername = iCloudMailboxUsername;
|
||||||
}
|
}
|
||||||
else if (details.SpecialImapProvider == SpecialImapProvider.Yahoo)
|
else if (details.SpecialImapProvider == SpecialImapProvider.Yahoo)
|
||||||
{
|
{
|
||||||
@@ -73,4 +75,17 @@ public class SpecialImapProviderConfigResolver : ISpecialImapProviderConfigResol
|
|||||||
|
|
||||||
return resolvedConfig;
|
return resolvedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetICloudMailboxUsername(string address)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(address))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var normalizedAddress = address.Trim();
|
||||||
|
var atIndex = normalizedAddress.IndexOf('@');
|
||||||
|
|
||||||
|
return atIndex > 0
|
||||||
|
? normalizedAddress[..atIndex]
|
||||||
|
: normalizedAddress;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user