14 Commits

Author SHA1 Message Date
Burak Kaan Köse ef12e0ff3a Init translations before showing nwe version notification. 2026-04-25 22:48:31 +02:00
Burak Kaan Köse e7e201758e Handle expired subscriptions. 2026-04-25 22:48:15 +02:00
Burak Kaan Köse 55ae6e1f3a Initial v2 launch notification. 2026-04-25 16:12:49 +02:00
Burak Kaan Köse 3ea8b8ac46 Bulk updates for gmail. 2026-04-25 16:00:49 +02:00
Burak Kaan Köse 8fe40e9fe3 Potential gmail history id fix. 2026-04-23 15:00:55 +02:00
Burak Kaan Köse 6f82cd4f26 Save as eml, translation updates 2026-04-23 13:23:05 +02:00
Burak Kaan Köse 81e28129b7 Set default language based on Windows language. 2026-04-22 22:39:19 +02:00
Burak Kaan Köse 39cde10fab Fix signature settings persistence and editing 2026-04-22 13:25:07 +02:00
Burak Kaan Köse ed54eb0284 Beta 2.0.6.0 2026-04-22 13:11:33 +02:00
Burak Kaan Köse d66015bebd Fixed iCloud/Yahoo special providers and implemented more calendar metadata to support calendar colors in CalDav synchronizer. 2026-04-22 09:55:13 +02:00
Burak Kaan Köse 890bfc84f1 Allow Outlook reauth when UPN differs from mailbox 2026-04-22 01:43:53 +02:00
Burak Kaan Köse 59505d6985 Render mail categories in list items 2026-04-22 01:28:18 +02:00
Burak Kaan Köse 09820dda71 Add local mail pinning support 2026-04-21 23:17:08 +02:00
Burak Kaan Köse c0023614ad Harden junk mail image tracking protection 2026-04-21 22:21:59 +02:00
93 changed files with 4283 additions and 315 deletions
+2 -12
View File
@@ -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}";
} }
+2 -1
View File
@@ -16,5 +16,6 @@ public enum AppLanguage
Greek, Greek,
PortugeseBrazil, PortugeseBrazil,
Italian, Italian,
Romanian Romanian,
Korean
} }
+16 -12
View File
@@ -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
} }
@@ -2,6 +2,8 @@
public interface IConfigurationService public interface IConfigurationService
{ {
bool Contains(string key);
void Set(string key, object value); void Set(string key, object value);
T Get<T>(string key, T defaultValue = default); T Get<T>(string key, T defaultValue = default);
@@ -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.
@@ -33,15 +34,20 @@ public interface IMailService
/// <param name="accountId">Account to remove from</param> /// <param name="accountId">Account to remove from</param>
/// <param name="mailCopyId">Mail copy id to remove.</param> /// <param name="mailCopyId">Mail copy id to remove.</param>
Task DeleteMailAsync(Guid accountId, string mailCopyId); Task DeleteMailAsync(Guid accountId, string mailCopyId);
Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailCopyIds);
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);
Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
Task CreateAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package); Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package);
Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages);
/// <summary> /// <summary>
/// Maps new mail item with the existing local draft copy. /// Maps new mail item with the existing local draft copy.
@@ -51,6 +51,11 @@ public interface INotificationBuilder
/// </summary> /// </summary>
void CreateStoreUpdateNotification(); void CreateStoreUpdateNotification();
/// <summary>
/// Shows the one-time release migration notification.
/// </summary>
void CreateReleaseMigrationNotification();
/// <summary> /// <summary>
/// Creates a calendar reminder toast for the specified calendar item. /// Creates a calendar reminder toast for the specified calendar item.
/// </summary> /// </summary>
@@ -127,6 +127,11 @@ public interface IPreferencesService : INotifyPropertyChanged
/// </summary> /// </summary>
bool IsHardDeleteProtectionEnabled { get; set; } bool IsHardDeleteProtectionEnabled { get; set; }
/// <summary>
/// Setting: Show the empty-folder command for junk/spam folders.
/// </summary>
bool IsShowEmptyJunkFolderEnabled { get; set; }
/// <summary> /// <summary>
/// Setting: Thread mails into conversations. /// Setting: Thread mails into conversations.
/// </summary> /// </summary>
@@ -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,3 @@
namespace Wino.Core.Domain.Models.MailItem;
public sealed record MailFolderAssignmentUpdate(string MailCopyId, string RemoteFolderId);
@@ -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);
}
@@ -45,6 +45,7 @@ public class WinoTranslationDictionary : Dictionary<string, string>
AppLanguage.Greek => "el_GR", AppLanguage.Greek => "el_GR",
AppLanguage.PortugeseBrazil => "pt_BR", AppLanguage.PortugeseBrazil => "pt_BR",
AppLanguage.Romanian => "ro_RO", AppLanguage.Romanian => "ro_RO",
AppLanguage.Korean => "ko_KR",
_ => "en_US", _ => "en_US",
}; };
} }
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Изтриване на този акаунт", "SettingsDeleteAccount_Title": "Изтриване на този акаунт",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Защита от окончателно изтриване", "SettingsDeleteProtection_Title": "Защита от окончателно изтриване",
"SettingsEmptyJunkFolderCommand_Description": "Покажи командата за изпразване на папка в папките Спам. Това действие няма да изисква потвърждение и ще изтрие всички имейли в папката за спам веднага.",
"SettingsEmptyJunkFolderCommand_Title": "Покажи командата за изпразване на папка Спам",
"SettingsDiagnostics_Description": "За разработчици", "SettingsDiagnostics_Description": "За разработчици",
"SettingsDiagnostics_DiagnosticId_Description": "Споделете този идентификационен номер с разработчиците, когато ви помолят, за да получите помощ за проблемите, с които се сблъсквате в Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Споделете този идентификационен номер с разработчиците, когато ви помолят, за да получите помощ за проблемите, с които се сблъсквате в Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Идентификатор за диагностика", "SettingsDiagnostics_DiagnosticId_Title": "Идентификатор за диагностика",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Delete this account", "SettingsDeleteAccount_Title": "Delete this account",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Permanent Delete Protection", "SettingsDeleteProtection_Title": "Permanent Delete Protection",
"SettingsEmptyJunkFolderCommand_Description": "Mostra la comanda per buidar la carpeta de correu brossa. Aquesta acció no demanarà confirmació i eliminarà immediatament tots els correus de la carpeta de correu brossa.",
"SettingsEmptyJunkFolderCommand_Title": "Mostra la comanda per buidar la carpeta de correu brossa",
"SettingsDiagnostics_Description": "For developers", "SettingsDiagnostics_Description": "For developers",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Smazat tento účet", "SettingsDeleteAccount_Title": "Smazat tento účet",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Ochrana proti trvalému smazání", "SettingsDeleteProtection_Title": "Ochrana proti trvalému smazání",
"SettingsEmptyJunkFolderCommand_Description": "Zobrazit příkaz pro vyprázdnění složky ve složkách Nevyžádaná pošta (spam). Tato akce nepožádá o potvrzení a ihned smaže všechny e-maily ve složce spamu.",
"SettingsEmptyJunkFolderCommand_Title": "Zobrazit příkaz pro vyprázdnění složky spamu",
"SettingsDiagnostics_Description": "Pro vývojáře", "SettingsDiagnostics_Description": "Pro vývojáře",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Delete this account", "SettingsDeleteAccount_Title": "Delete this account",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Permanent Delete Protection", "SettingsDeleteProtection_Title": "Permanent Delete Protection",
"SettingsEmptyJunkFolderCommand_Description": "Vis kommandoen til at tøm mappen i Junk/Spam-mapperne. Denne handling vil ikke bede om bekræftelse og vil straks slette alle mails i spam-mappen.",
"SettingsEmptyJunkFolderCommand_Title": "Vis kommandoen til at tømme spam-mappen",
"SettingsDiagnostics_Description": "For developers", "SettingsDiagnostics_Description": "For developers",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Dieses Konto löschen", "SettingsDeleteAccount_Title": "Dieses Konto löschen",
"SettingsDeleteProtection_Description": "Sollte Wino jedes Mal nachfragen, wenn Sie eine Mail mit Umschalten + Entfernen permanent löschen möchten?", "SettingsDeleteProtection_Description": "Sollte Wino jedes Mal nachfragen, wenn Sie eine Mail mit Umschalten + Entfernen permanent löschen möchten?",
"SettingsDeleteProtection_Title": "Schutz vor permanenter Löschung", "SettingsDeleteProtection_Title": "Schutz vor permanenter Löschung",
"SettingsEmptyJunkFolderCommand_Description": "Zeige den Befehl zum Leeren des Junk-/Spam-Ordners an. Diese Aktion wird keine Bestätigung erfordern und löscht sofort alle E-Mails im Spam-Ordner.",
"SettingsEmptyJunkFolderCommand_Title": "Befehl zum Leeren des Spam-Ordners anzeigen",
"SettingsDiagnostics_Description": "Für Entwickler", "SettingsDiagnostics_Description": "Für Entwickler",
"SettingsDiagnostics_DiagnosticId_Description": "Teilen Sie diese ID mit den Entwicklern, wenn Sie um Hilfe bei Problemen in Wino Mail gebeten werden.", "SettingsDiagnostics_DiagnosticId_Description": "Teilen Sie diese ID mit den Entwicklern, wenn Sie um Hilfe bei Problemen in Wino Mail gebeten werden.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnose-ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnose-ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Διαγραφή αυτού του λογαριασμού", "SettingsDeleteAccount_Title": "Διαγραφή αυτού του λογαριασμού",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Προστασία Μόνιμης Διαγραφής", "SettingsDeleteProtection_Title": "Προστασία Μόνιμης Διαγραφής",
"SettingsEmptyJunkFolderCommand_Description": "Εμφάνιση της εντολής εκκένωσης φακέλου στους φακέλους Junk/Spam. Αυτή η ενέργεια δεν θα ζητήσει επιβεβαίωση και θα διαγράψει αμέσως όλα τα ηλεκτρονικά μηνύματα στον φάκελο Spam.",
"SettingsEmptyJunkFolderCommand_Title": "Εμφάνιση εντολής εκκένωσης φακέλου Spam",
"SettingsDiagnostics_Description": "Για προγραμματιστές", "SettingsDiagnostics_Description": "Για προγραμματιστές",
"SettingsDiagnostics_DiagnosticId_Description": "Μοιραστείτε αυτό το ID με τους προγραμματιστές όταν σας ζητηθεί να λάβετε βοήθεια για τα θέματα που αντιμετωπίζετε στο Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Μοιραστείτε αυτό το ID με τους προγραμματιστές όταν σας ζητηθεί να λάβετε βοήθεια για τα θέματα που αντιμετωπίζετε στο Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Διαγνωστικό ID", "SettingsDiagnostics_DiagnosticId_Title": "Διαγνωστικό ID",
@@ -92,11 +92,13 @@
"Buttons_Discard": "Discard", "Buttons_Discard": "Discard",
"Buttons_Dismiss": "Dismiss", "Buttons_Dismiss": "Dismiss",
"Buttons_Edit": "Edit", "Buttons_Edit": "Edit",
"Buttons_EML": "EML",
"Buttons_EnableImageRendering": "Enable", "Buttons_EnableImageRendering": "Enable",
"Buttons_Multiselect": "Select Multiple", "Buttons_Multiselect": "Select Multiple",
"Buttons_Manage": "Manage", "Buttons_Manage": "Manage",
"Buttons_No": "No", "Buttons_No": "No",
"Buttons_Open": "Open", "Buttons_Open": "Open",
"Buttons_PDF": "PDF",
"Buttons_Purchase": "Purchase", "Buttons_Purchase": "Purchase",
"Buttons_RateWino": "Rate Wino", "Buttons_RateWino": "Rate Wino",
"Buttons_Reset": "Reset", "Buttons_Reset": "Reset",
@@ -595,6 +597,9 @@
"Info_MessageCorruptedTitle": "Error", "Info_MessageCorruptedTitle": "Error",
"Info_MissingFolderMessage": "{0} doesn't exist for this account.", "Info_MissingFolderMessage": "{0} doesn't exist for this account.",
"Info_MissingFolderTitle": "Missing Folder", "Info_MissingFolderTitle": "Missing Folder",
"Info_EMLSaveFailedTitle": "Failed to save EML file",
"Info_EMLSaveSuccessMessage": "EML file is saved to {0}",
"Info_EMLSaveSuccessTitle": "Success",
"Info_PDFSaveFailedTitle": "Failed to save PDF file", "Info_PDFSaveFailedTitle": "Failed to save PDF file",
"Info_PDFSaveSuccessMessage": "PDF file is saved to {0}", "Info_PDFSaveSuccessMessage": "PDF file is saved to {0}",
"Info_PDFSaveSuccessTitle": "Success", "Info_PDFSaveSuccessTitle": "Success",
@@ -699,6 +704,8 @@
"Notifications_WinoUpdatedTitle": "Wino Mail has been updated.", "Notifications_WinoUpdatedTitle": "Wino Mail has been updated.",
"Notifications_StoreUpdateAvailableTitle": "Update available", "Notifications_StoreUpdateAvailableTitle": "Update available",
"Notifications_StoreUpdateAvailableMessage": "A newer version of Wino Mail is ready to install from Microsoft Store.", "Notifications_StoreUpdateAvailableMessage": "A newer version of Wino Mail is ready to install from Microsoft Store.",
"Notifications_ReleaseMigrationTitle": "New Wino Mail & Calendar",
"Notifications_ReleaseMigrationMessage": "Wino Mail got updated to the next version. Please re-create your accounts and start using the next version including calendar, mail templates, shortcuts, and a bunch of other improvements.",
"OnlineSearchFailed_Message": "Failed to perform search\n{0}\n\nListing offline mails.", "OnlineSearchFailed_Message": "Failed to perform search\n{0}\n\nListing offline mails.",
"OnlineSearchTry_Line1": "Can't find what you are looking for?", "OnlineSearchTry_Line1": "Can't find what you are looking for?",
"OnlineSearchTry_Line2": "Try online search.", "OnlineSearchTry_Line2": "Try online search.",
@@ -836,6 +843,8 @@
"SettingsDeleteAccount_Title": "Delete this account", "SettingsDeleteAccount_Title": "Delete this account",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Permanent Delete Protection", "SettingsDeleteProtection_Title": "Permanent Delete Protection",
"SettingsEmptyJunkFolderCommand_Description": "Show the empty-folder command in Junk/Spam folders. This action will not ask for confirmation and will delete all mails in the spam folder immediately.",
"SettingsEmptyJunkFolderCommand_Title": "Show empty spam folder command",
"SettingsDiagnostics_Description": "For developers", "SettingsDiagnostics_Description": "For developers",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Eliminar esta cuenta", "SettingsDeleteAccount_Title": "Eliminar esta cuenta",
"SettingsDeleteProtection_Description": "¿Debería Wino pedirte confirmación cada vez que intentas eliminar un correo usando las teclas Shift + Supr?", "SettingsDeleteProtection_Description": "¿Debería Wino pedirte confirmación cada vez que intentas eliminar un correo usando las teclas Shift + Supr?",
"SettingsDeleteProtection_Title": "Protección de Eliminación Permanente", "SettingsDeleteProtection_Title": "Protección de Eliminación Permanente",
"SettingsEmptyJunkFolderCommand_Description": "Mostrar el comando para vaciar la carpeta en las carpetas de correo no deseado. Esta acción no solicitará confirmación y eliminará todos los correos de la carpeta de correo no deseado de inmediato.",
"SettingsEmptyJunkFolderCommand_Title": "Mostrar el comando para vaciar la carpeta de correo no deseado",
"SettingsDiagnostics_Description": "Para desarrolladores", "SettingsDiagnostics_Description": "Para desarrolladores",
"SettingsDiagnostics_DiagnosticId_Description": "Comparte este ID con los desarrolladores cuando se les pida ayuda para los problemas que experimentas en Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Comparte este ID con los desarrolladores cuando se les pida ayuda para los problemas que experimentas en Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "ID de Diagnóstico", "SettingsDiagnostics_DiagnosticId_Title": "ID de Diagnóstico",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Delete this account", "SettingsDeleteAccount_Title": "Delete this account",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Permanent Delete Protection", "SettingsDeleteProtection_Title": "Permanent Delete Protection",
"SettingsEmptyJunkFolderCommand_Description": "Näytä roskapostikansioiden tyhjennyskomento. Tämä toimenpide ei kysy vahvistusta ja poistaa välittömästi kaikki sähköpostit roskapostikansioista.",
"SettingsEmptyJunkFolderCommand_Title": "Näytä roskapostikansioiden tyhjennyskomento",
"SettingsDiagnostics_Description": "For developers", "SettingsDiagnostics_Description": "For developers",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Supprimer ce compte", "SettingsDeleteAccount_Title": "Supprimer ce compte",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Protection contre la suppression permanente", "SettingsDeleteProtection_Title": "Protection contre la suppression permanente",
"SettingsEmptyJunkFolderCommand_Description": "Afficher la commande de vidage du dossier dans les dossiers de courrier indésirable. Cette action ne demandera pas de confirmation et supprimera immédiatement tous les courriels du dossier de courrier indésirable.",
"SettingsEmptyJunkFolderCommand_Title": "Afficher la commande pour vider le dossier de courrier indésirable",
"SettingsDiagnostics_Description": "Pour les développeurs", "SettingsDiagnostics_Description": "Pour les développeurs",
"SettingsDiagnostics_DiagnosticId_Description": "Partagez cet identifiant avec les développeurs lorsqu'ils vous aideront à résoudre les problèmes que vous rencontrez dans Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Partagez cet identifiant avec les développeurs lorsqu'ils vous aideront à résoudre les problèmes que vous rencontrez dans Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "ID de diagnostic", "SettingsDiagnostics_DiagnosticId_Title": "ID de diagnostic",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Delete this account", "SettingsDeleteAccount_Title": "Delete this account",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Permanent Delete Protection", "SettingsDeleteProtection_Title": "Permanent Delete Protection",
"SettingsEmptyJunkFolderCommand_Description": "Mostrar o comando para baleirar a carpeta de correo lixo. Esta acción non pedirá confirmación e eliminará de inmediato todos os correos da carpeta de correo lixo.",
"SettingsEmptyJunkFolderCommand_Title": "Mostrar o comando para baleirar a carpeta de correo lixo",
"SettingsDiagnostics_Description": "For developers", "SettingsDiagnostics_Description": "For developers",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Hapus akun ini", "SettingsDeleteAccount_Title": "Hapus akun ini",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Perlindungan Hapus Permanen", "SettingsDeleteProtection_Title": "Perlindungan Hapus Permanen",
"SettingsEmptyJunkFolderCommand_Description": "Tampilkan perintah kosongkan folder pada folder Junk/Spam. Tindakan ini tidak akan meminta konfirmasi dan akan menghapus semua email di folder spam segera.",
"SettingsEmptyJunkFolderCommand_Title": "Perintah kosongkan folder Spam",
"SettingsDiagnostics_Description": "Untuk pengembang", "SettingsDiagnostics_Description": "Untuk pengembang",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Elimina questo account", "SettingsDeleteAccount_Title": "Elimina questo account",
"SettingsDeleteProtection_Description": "Dovrebbe Wino chiederti la conferma ogni volta che provi a eliminare definitivamente un messaggio utilizzando Maiusc + Canc?", "SettingsDeleteProtection_Description": "Dovrebbe Wino chiederti la conferma ogni volta che provi a eliminare definitivamente un messaggio utilizzando Maiusc + Canc?",
"SettingsDeleteProtection_Title": "Protezione eliminazione permanente", "SettingsDeleteProtection_Title": "Protezione eliminazione permanente",
"SettingsEmptyJunkFolderCommand_Description": "Mostra il comando per svuotare le cartelle Junk/Spam. Questa azione non richiederà alcuna conferma e eliminerà immediatamente tutte le email nelle cartelle Junk/Spam.",
"SettingsEmptyJunkFolderCommand_Title": "Mostra il comando per svuotare la cartella Junk/Spam",
"SettingsDiagnostics_Description": "Per gli sviluppatori", "SettingsDiagnostics_Description": "Per gli sviluppatori",
"SettingsDiagnostics_DiagnosticId_Description": "Condividi questo ID con gli sviluppatori quando richiesto per ricevere aiuto in merito ai problemi che hai con Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Condividi questo ID con gli sviluppatori quando richiesto per ricevere aiuto in merito ai problemi che hai con Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "ID diagnostica", "SettingsDiagnostics_DiagnosticId_Title": "ID diagnostica",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Delete this account", "SettingsDeleteAccount_Title": "Delete this account",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Permanent Delete Protection", "SettingsDeleteProtection_Title": "Permanent Delete Protection",
"SettingsEmptyJunkFolderCommand_Description": "迷惑メール フォルダーの「空にする」コマンドを表示します。この操作は確認を求めず、迷惑メール フォルダー内のすべてのメールを即座に削除します。",
"SettingsEmptyJunkFolderCommand_Title": "迷惑メール フォルダーを空にするコマンドを表示",
"SettingsDiagnostics_Description": "For developers", "SettingsDiagnostics_Description": "For developers",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
File diff suppressed because it is too large Load Diff
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Delete this account", "SettingsDeleteAccount_Title": "Delete this account",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Permanent Delete Protection", "SettingsDeleteProtection_Title": "Permanent Delete Protection",
"SettingsEmptyJunkFolderCommand_Description": "Rodyti komandą Išvalyti šlamšto aplanką šlamšto aplankuose. Šis veiksmas nepaprašys patvirtinimo ir nedelsiant ištrins visus el. laiškus šlamšto aplanke.",
"SettingsEmptyJunkFolderCommand_Title": "Rodyti komandą Išvalyti šlamšto aplanką",
"SettingsDiagnostics_Description": "For developers", "SettingsDiagnostics_Description": "For developers",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Verwijder dit account", "SettingsDeleteAccount_Title": "Verwijder dit account",
"SettingsDeleteProtection_Description": "Moet Wino u om bevestiging vragen elke keer wanneer u een e-mail permanent verwijdert met de Shift + Del toetsen?", "SettingsDeleteProtection_Description": "Moet Wino u om bevestiging vragen elke keer wanneer u een e-mail permanent verwijdert met de Shift + Del toetsen?",
"SettingsDeleteProtection_Title": "Bescherming tegen permanent verwijderen", "SettingsDeleteProtection_Title": "Bescherming tegen permanent verwijderen",
"SettingsEmptyJunkFolderCommand_Description": "Toon het lege-map-commando in de Junk/Spam-mappen. Deze actie vraagt geen bevestiging en verwijdert onmiddellijk alle e-mails in de spam-map.",
"SettingsEmptyJunkFolderCommand_Title": "Toon leeg-map-commando",
"SettingsDiagnostics_Description": "Voor ontwikkelaars", "SettingsDiagnostics_Description": "Voor ontwikkelaars",
"SettingsDiagnostics_DiagnosticId_Description": "Deel dit ID met de ontwikkelaars wanneer er om hulp gevraagd wordt voor de problemen die u in Wino Mail ervaart.", "SettingsDiagnostics_DiagnosticId_Description": "Deel dit ID met de ontwikkelaars wanneer er om hulp gevraagd wordt voor de problemen die u in Wino Mail ervaart.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostische ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostische ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Usuń to konto", "SettingsDeleteAccount_Title": "Usuń to konto",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Ochrona przed trwałym usunięciem", "SettingsDeleteProtection_Title": "Ochrona przed trwałym usunięciem",
"SettingsEmptyJunkFolderCommand_Description": "Pokaż polecenie opróżniania pustego folderu w folderach Junk/Spam. Ta akcja nie poprosi o potwierdzenie i natychmiast usunie wszystkie wiadomości z folderu Spam.",
"SettingsEmptyJunkFolderCommand_Title": "Pokaż polecenie opróżniania pustego folderu Spam",
"SettingsDiagnostics_Description": "Dla programistów", "SettingsDiagnostics_Description": "Dla programistów",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Apagar esta conta", "SettingsDeleteAccount_Title": "Apagar esta conta",
"SettingsDeleteProtection_Description": "O Wino deve solicitar confirmação sempre que você tentar excluir permanentemente um e-mail usando as teclas Shift + Del?", "SettingsDeleteProtection_Description": "O Wino deve solicitar confirmação sempre que você tentar excluir permanentemente um e-mail usando as teclas Shift + Del?",
"SettingsDeleteProtection_Title": "Proteção de exclusão permanente", "SettingsDeleteProtection_Title": "Proteção de exclusão permanente",
"SettingsEmptyJunkFolderCommand_Description": "Mostrar o comando para esvaziar a pasta nas pastas de Lixo/Spam. Esta ação não solicitará confirmação e excluirá imediatamente todas as mensagens na pasta de spam.",
"SettingsEmptyJunkFolderCommand_Title": "Mostrar o comando para esvaziar a pasta de Spam",
"SettingsDiagnostics_Description": "Para desenvolvedores", "SettingsDiagnostics_Description": "Para desenvolvedores",
"SettingsDiagnostics_DiagnosticId_Description": "Compartilhe esta identificação com os desenvolvedores quando solicitado para obter ajuda para os problemas que você experimentar no Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Compartilhe esta identificação com os desenvolvedores quando solicitado para obter ajuda para os problemas que você experimentar no Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "ID de diagnóstico", "SettingsDiagnostics_DiagnosticId_Title": "ID de diagnóstico",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Ștergeți acest cont", "SettingsDeleteAccount_Title": "Ștergeți acest cont",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Protecție Ștergere Permanentă", "SettingsDeleteProtection_Title": "Protecție Ștergere Permanentă",
"SettingsEmptyJunkFolderCommand_Description": "Afișează comanda de golire a folderelor Junk/Spam. Această acțiune nu va solicita confirmare și va șterge imediat toate mesajele din folderul de spam.",
"SettingsEmptyJunkFolderCommand_Title": "Afișează comanda pentru golirea folderului de spam.",
"SettingsDiagnostics_Description": "Pentru dezvoltatori", "SettingsDiagnostics_Description": "Pentru dezvoltatori",
"SettingsDiagnostics_DiagnosticId_Description": "Partajați acest ID cu dezvoltatorii atunci când le cereți ajutor pentru problemele pe care le întâmpinați în Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Partajați acest ID cu dezvoltatorii atunci când le cereți ajutor pentru problemele pe care le întâmpinați în Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "ID de Diagnosticare", "SettingsDiagnostics_DiagnosticId_Title": "ID de Diagnosticare",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Удалить эту учетную запись", "SettingsDeleteAccount_Title": "Удалить эту учетную запись",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Защита от окончательного удаления", "SettingsDeleteProtection_Title": "Защита от окончательного удаления",
"SettingsEmptyJunkFolderCommand_Description": "Показать команду очистки пустой папки в папках Спам. Это действие не будет запрашивать подтверждение и сразу удалит все письма в папке спама.",
"SettingsEmptyJunkFolderCommand_Title": "Показать команду очистки пустой папки в папках Спам",
"SettingsDiagnostics_Description": "Для разработчиков", "SettingsDiagnostics_Description": "Для разработчиков",
"SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Share this ID with the developers when asked to get help for the issues you experience in Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostic ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Odstrániť tento účet", "SettingsDeleteAccount_Title": "Odstrániť tento účet",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Ochrana pred trvalým odstránením", "SettingsDeleteProtection_Title": "Ochrana pred trvalým odstránením",
"SettingsEmptyJunkFolderCommand_Description": "Zobraziť príkaz na vyprázdnenie priečinka v priečinkoch Nevyžiadaná pošta / Spam. Táto akcia nepožaduje potvrdenie a okamžite vymaže všetky e-maily zo spamového priečinka.",
"SettingsEmptyJunkFolderCommand_Title": "Zobraziť príkaz na vyprázdnenie spamového priečinka",
"SettingsDiagnostics_Description": "Pre vývojárov", "SettingsDiagnostics_Description": "Pre vývojárov",
"SettingsDiagnostics_DiagnosticId_Description": "Zdieľajte toto ID s vývojármi, keď budete požiadaní o pomoc pri problémoch, ktoré sa vám vyskytnú v aplikácii Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Zdieľajte toto ID s vývojármi, keď budete požiadaní o pomoc pri problémoch, ktoré sa vám vyskytnú v aplikácii Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "Diagnostické ID", "SettingsDiagnostics_DiagnosticId_Title": "Diagnostické ID",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Bu hesabı sil", "SettingsDeleteAccount_Title": "Bu hesabı sil",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Kalıcı Silme Koruması", "SettingsDeleteProtection_Title": "Kalıcı Silme Koruması",
"SettingsEmptyJunkFolderCommand_Description": "Gereksiz Posta/Spam klasörlerinde boş klasör komutunu göster. Bu işlem onay istemeyecek ve spam klasöründeki tüm mailleri hemen silecektir.",
"SettingsEmptyJunkFolderCommand_Title": "Boş Spam Klasörü Komutunu Göster",
"SettingsDiagnostics_Description": "Geliştiriciler için", "SettingsDiagnostics_Description": "Geliştiriciler için",
"SettingsDiagnostics_DiagnosticId_Description": "Bu ID'yi geliştiriciler ile paylaşarak Wino Mail ile yaşadığınız sorunlar hakkında çözümlere ulaşabilirsiniz.", "SettingsDiagnostics_DiagnosticId_Description": "Bu ID'yi geliştiriciler ile paylaşarak Wino Mail ile yaşadığınız sorunlar hakkında çözümlere ulaşabilirsiniz.",
"SettingsDiagnostics_DiagnosticId_Title": "Hata Ayıklama ID'si", "SettingsDiagnostics_DiagnosticId_Title": "Hata Ayıklama ID'si",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "Видалити цей обліковий запис", "SettingsDeleteAccount_Title": "Видалити цей обліковий запис",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "Захист від остаточного видалення", "SettingsDeleteProtection_Title": "Захист від остаточного видалення",
"SettingsEmptyJunkFolderCommand_Description": "Показати команду очищення порожньої папки у папках Небажана пошта/Спам. Ця дія не вимагатиме підтвердження і негайно видалить усі листи з папки зі спамом.",
"SettingsEmptyJunkFolderCommand_Title": "Показати команду очищення порожньої папки Спам",
"SettingsDiagnostics_Description": "Для розробників", "SettingsDiagnostics_Description": "Для розробників",
"SettingsDiagnostics_DiagnosticId_Description": "Поділіться цим ID з розробниками, коли попросять, щоб отримати допомогу щодо проблем, з якими Ви зіткнулися у Wino Mail.", "SettingsDiagnostics_DiagnosticId_Description": "Поділіться цим ID з розробниками, коли попросять, щоб отримати допомогу щодо проблем, з якими Ви зіткнулися у Wino Mail.",
"SettingsDiagnostics_DiagnosticId_Title": "ID діагностики", "SettingsDiagnostics_DiagnosticId_Title": "ID діагностики",
@@ -836,6 +836,8 @@
"SettingsDeleteAccount_Title": "删除此账户", "SettingsDeleteAccount_Title": "删除此账户",
"SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?", "SettingsDeleteProtection_Description": "Should Wino ask you for confirmation every time you try to permanently delete a mail using Shift + Del keys?",
"SettingsDeleteProtection_Title": "永久性删除保护", "SettingsDeleteProtection_Title": "永久性删除保护",
"SettingsEmptyJunkFolderCommand_Description": "在垃圾邮件/垃圾箱文件夹中显示清空文件夹命令。此操作不会要求确认,将立即删除垃圾邮件文件夹中的所有邮件。",
"SettingsEmptyJunkFolderCommand_Title": "显示清空垃圾邮件文件夹的命令",
"SettingsDiagnostics_Description": "开发者选项", "SettingsDiagnostics_Description": "开发者选项",
"SettingsDiagnostics_DiagnosticId_Description": "如需联系开发人员请求帮助,请提供此 ID。", "SettingsDiagnostics_DiagnosticId_Description": "如需联系开发人员请求帮助,请提供此 ID。",
"SettingsDiagnostics_DiagnosticId_Title": "诊断 ID", "SettingsDiagnostics_DiagnosticId_Title": "诊断 ID",
@@ -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,24 @@
using FluentAssertions;
using Wino.Core.Domain.Enums;
using Wino.Services;
using Xunit;
namespace Wino.Core.Tests.Services;
public class TranslationServiceTests
{
[Theory]
[InlineData("pl-PL", AppLanguage.Polish)]
[InlineData("de-AT", AppLanguage.Deutsch)]
[InlineData("pt-PT", AppLanguage.PortugeseBrazil)]
[InlineData("zh-TW", AppLanguage.Chinese)]
[InlineData("tr_TR", AppLanguage.Turkish)]
[InlineData("ko-KR", AppLanguage.Korean)]
[InlineData("nl-NL", AppLanguage.English)]
public void ResolveSupportedLanguage_ReturnsExpectedLanguage(string languageTag, AppLanguage expectedLanguage)
{
var result = TranslationService.ResolveSupportedLanguage([languageTag]);
result.Should().Be(expectedLanguage);
}
}
@@ -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);
}
}
@@ -19,6 +19,32 @@ namespace Wino.Core.Tests.Synchronizers;
public sealed class GmailSynchronizerRequestSuccessTests public sealed class GmailSynchronizerRequestSuccessTests
{ {
[Fact]
public async Task UpdateAccountSyncIdentifierAsync_EmptyStoredIdentifier_PersistsFirstHistoryCursor()
{
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
changeProcessor
.Setup(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), "123"))
.ReturnsAsync("123");
var synchronizer = CreateSynchronizer(changeProcessor.Object, synchronizationDeltaIdentifier: string.Empty);
await InvokeUpdateAccountSyncIdentifierAsync(synchronizer, 123);
changeProcessor.Verify(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), "123"), Times.Once);
}
[Fact]
public async Task UpdateAccountSyncIdentifierAsync_OlderHistoryCursor_DoesNotRegressStoredCursor()
{
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
var synchronizer = CreateSynchronizer(changeProcessor.Object, synchronizationDeltaIdentifier: "456");
await InvokeUpdateAccountSyncIdentifierAsync(synchronizer, 123);
changeProcessor.Verify(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), It.IsAny<string>()), Times.Never);
}
[Fact] [Fact]
public async Task ProcessSingleNativeRequestResponseAsync_BatchMarkReadRequest_PersistsLocalReadStateForEachMail() public async Task ProcessSingleNativeRequestResponseAsync_BatchMarkReadRequest_PersistsLocalReadStateForEachMail()
{ {
@@ -209,13 +235,15 @@ public sealed class GmailSynchronizerRequestSuccessTests
private static GmailSynchronizer CreateSynchronizer( private static GmailSynchronizer CreateSynchronizer(
IGmailChangeProcessor changeProcessor, IGmailChangeProcessor changeProcessor,
IGmailSynchronizerErrorHandlerFactory? errorFactory = null) IGmailSynchronizerErrorHandlerFactory? errorFactory = null,
string? synchronizationDeltaIdentifier = null)
{ {
var account = new MailAccount var account = new MailAccount
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Name = "Gmail", Name = "Gmail",
Address = "user@example.com" Address = "user@example.com",
SynchronizationDeltaIdentifier = synchronizationDeltaIdentifier
}; };
var authenticator = new Mock<IGmailAuthenticator>(MockBehavior.Loose); var authenticator = new Mock<IGmailAuthenticator>(MockBehavior.Loose);
@@ -249,4 +277,17 @@ public sealed class GmailSynchronizerRequestSuccessTests
task.Should().NotBeNull(); task.Should().NotBeNull();
await task!; await task!;
} }
private static async Task InvokeUpdateAccountSyncIdentifierAsync(GmailSynchronizer synchronizer, ulong historyId)
{
var method = typeof(GmailSynchronizer).GetMethod(
"UpdateAccountSyncIdentifierAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
method.Should().NotBeNull();
var task = method!.Invoke(synchronizer, [historyId]) as Task;
task.Should().NotBeNull();
await task!;
}
} }
@@ -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
{ {
@@ -571,6 +571,19 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
} }
var aiStatus = aiStatusResponse.Result; var aiStatus = aiStatusResponse.Result;
if (IsExpiredAiEntitlement(aiStatus.EntitlementStatus))
{
await ExecuteUIThread(() =>
{
_aiPackAddOn.IsPurchased = false;
_aiPackAddOn.HasUsageData = false;
_aiPackAddOn.ErrorText = string.Empty;
_aiPackAddOn.RenewalText = string.Empty;
_aiPackAddOn.UsageResetText = string.Empty;
});
return;
}
if (aiStatus.MonthlyLimit is not int usageLimit || usageLimit <= 0 || aiStatus.Used is not int usageCount) if (aiStatus.MonthlyLimit is not int usageLimit || usageLimit <= 0 || aiStatus.Used is not int usageCount)
{ {
await ExecuteUIThread(() => await ExecuteUIThread(() =>
@@ -613,4 +626,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
}); });
} }
} }
private static bool IsExpiredAiEntitlement(string? entitlementStatus)
=> string.Equals(entitlementStatus, "Expired", StringComparison.OrdinalIgnoreCase);
} }
@@ -30,7 +30,9 @@ public interface IDefaultChangeProcessor
Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead); Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead);
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged); Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
Task<bool> CreateMailAsync(Guid AccountId, NewMailItemPackage package); Task<bool> CreateMailAsync(Guid AccountId, NewMailItemPackage package);
Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages);
Task DeleteMailAsync(Guid accountId, string mailId); Task DeleteMailAsync(Guid accountId, string mailId);
Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailIds);
Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds); Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds);
Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId); Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId);
Task DeleteFolderAsync(Guid accountId, string remoteFolderId); Task DeleteFolderAsync(Guid accountId, string remoteFolderId);
@@ -56,6 +58,9 @@ public interface IDefaultChangeProcessor
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken); Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
Task<List<MailCopy>> GetMailCopiesAsync(IEnumerable<string> mailCopyIds); Task<List<MailCopy>> GetMailCopiesAsync(IEnumerable<string> mailCopyIds);
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package); Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates);
Task CreateAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
Task DeleteUserMailCacheAsync(Guid accountId); Task DeleteUserMailCacheAsync(Guid accountId);
Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping); Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping);
Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId); Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId);
@@ -156,18 +161,33 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
public Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead) public Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead)
=> MailService.ChangeReadStatusAsync(mailCopyId, isRead); => MailService.ChangeReadStatusAsync(mailCopyId, isRead);
public Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates)
=> MailService.ApplyMailStateUpdatesAsync(updates);
public Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId) public Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
=> MailService.DeleteAssignmentAsync(accountId, mailCopyId, remoteFolderId); => MailService.DeleteAssignmentAsync(accountId, mailCopyId, remoteFolderId);
public Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments)
=> MailService.DeleteAssignmentsAsync(accountId, assignments);
public Task DeleteMailAsync(Guid accountId, string mailId) public Task DeleteMailAsync(Guid accountId, string mailId)
=> MailService.DeleteMailAsync(accountId, mailId); => MailService.DeleteMailAsync(accountId, mailId);
public Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailIds)
=> MailService.DeleteMailsAsync(accountId, mailIds);
public Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package) public Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
=> MailService.CreateMailAsync(accountId, package); => MailService.CreateMailAsync(accountId, package);
public Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages)
=> MailService.CreateMailsAsync(accountId, packages);
public Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package) public Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package)
=> MailService.CreateMailRawAsync(account, mailItemFolder, package); => MailService.CreateMailRawAsync(account, mailItemFolder, package);
public Task CreateAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments)
=> MailService.CreateAssignmentsAsync(accountId, assignments);
public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId) public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
=> MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId); => MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId);
+163 -52
View File
@@ -1050,6 +1050,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{ {
_logger.Debug("Processing delta change {HistoryId} for {Name}", listHistoryResponse.HistoryId.GetValueOrDefault(), Account.Name); _logger.Debug("Processing delta change {HistoryId} for {Name}", listHistoryResponse.HistoryId.GetValueOrDefault(), Account.Name);
var pendingStateUpdates = new List<MailCopyStateUpdate>();
var pendingAssignmentCreates = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
var pendingAssignmentDeletes = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
var deletedMessageIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var history in listHistoryResponse.History) foreach (var history in listHistoryResponse.History)
{ {
// Handle label additions. // Handle label additions.
@@ -1057,7 +1062,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{ {
foreach (var addedLabel in history.LabelsAdded) foreach (var addedLabel in history.LabelsAdded)
{ {
await HandleLabelAssignmentAsync(addedLabel); await HandleLabelAssignmentAsync(addedLabel, pendingStateUpdates, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
} }
} }
@@ -1066,7 +1071,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{ {
foreach (var removedLabel in history.LabelsRemoved) foreach (var removedLabel in history.LabelsRemoved)
{ {
await HandleLabelRemovalAsync(removedLabel); await HandleLabelRemovalAsync(removedLabel, pendingStateUpdates, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
} }
} }
@@ -1079,36 +1084,108 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
_logger.Debug("Processing message deletion for {MessageId}", messageId); _logger.Debug("Processing message deletion for {MessageId}", messageId);
await _gmailChangeProcessor.DeleteMailAsync(Account.Id, messageId).ConfigureAwait(false); deletedMessageIds.Add(messageId);
} }
} }
} }
if (pendingStateUpdates.Count > 0)
{
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(pendingStateUpdates).ConfigureAwait(false);
}
if (pendingAssignmentCreates.Count > 0)
{
await _gmailChangeProcessor.CreateAssignmentsAsync(Account.Id, pendingAssignmentCreates.Values.ToList()).ConfigureAwait(false);
}
if (pendingAssignmentDeletes.Count > 0)
{
await _gmailChangeProcessor.DeleteAssignmentsAsync(Account.Id, pendingAssignmentDeletes.Values.ToList()).ConfigureAwait(false);
}
if (deletedMessageIds.Count > 0)
{
await _gmailChangeProcessor.DeleteMailsAsync(Account.Id, deletedMessageIds).ConfigureAwait(false);
}
} }
private async Task HandleArchiveAssignmentAsync(string archivedMessageId) private static string GetAssignmentChangeKey(string messageId, string labelId)
=> $"{messageId}\u001f{labelId}";
private static void QueueAssignmentChange(
Dictionary<string, MailFolderAssignmentUpdate> creates,
Dictionary<string, MailFolderAssignmentUpdate> deletes,
MailFolderAssignmentUpdate assignment,
bool shouldCreate)
{ {
if (assignment == null ||
string.IsNullOrWhiteSpace(assignment.MailCopyId) ||
string.IsNullOrWhiteSpace(assignment.RemoteFolderId))
{
return;
}
var key = GetAssignmentChangeKey(assignment.MailCopyId, assignment.RemoteFolderId);
if (shouldCreate)
{
deletes.Remove(key);
creates[key] = assignment;
}
else
{
creates.Remove(key);
deletes[key] = assignment;
}
}
private async Task HandleArchiveAssignmentAsync(
string archivedMessageId,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
{
if (!archiveFolderId.HasValue)
return;
// Ignore if the message is already in the archive. // Ignore if the message is already in the archive.
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(archivedMessageId, archiveFolderId.Value); bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(archivedMessageId, archiveFolderId.Value).ConfigureAwait(false);
if (archived) return; if (archived) return;
_logger.Debug("Processing archive assignment for message {Id}", archivedMessageId); _logger.Debug("Processing archive assignment for message {Id}", archivedMessageId);
QueueAssignmentChange(
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, archivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false); pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(archivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID),
shouldCreate: true);
} }
private async Task HandleUnarchiveAssignmentAsync(string unarchivedMessageId) private async Task HandleUnarchiveAssignmentAsync(
string unarchivedMessageId,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
{ {
if (!archiveFolderId.HasValue)
return;
// Ignore if the message is not in the archive. // Ignore if the message is not in the archive.
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(unarchivedMessageId, archiveFolderId.Value); bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(unarchivedMessageId, archiveFolderId.Value).ConfigureAwait(false);
if (!archived) return; if (!archived) return;
_logger.Debug("Processing un-archive assignment for message {Id}", unarchivedMessageId); _logger.Debug("Processing un-archive assignment for message {Id}", unarchivedMessageId);
QueueAssignmentChange(
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, unarchivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false); pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(unarchivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID),
shouldCreate: false);
} }
private async Task HandleLabelAssignmentAsync(HistoryLabelAdded addedLabel) private async Task HandleLabelAssignmentAsync(
HistoryLabelAdded addedLabel,
List<MailCopyStateUpdate> pendingStateUpdates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
{ {
var messageId = addedLabel.Message.Id; var messageId = addedLabel.Message.Id;
@@ -1119,23 +1196,31 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// ARCHIVE is a virtual folder - handle it separately // ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID) if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{ {
await HandleArchiveAssignmentAsync(messageId).ConfigureAwait(false); await HandleArchiveAssignmentAsync(messageId, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
continue; continue;
} }
// When UNREAD label is added mark the message as un-read. // When UNREAD label is added mark the message as un-read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID) if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false); pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsRead: false));
// When STARRED label is added mark the message as flagged. // When STARRED label is added mark the message as flagged.
if (labelId == ServiceConstants.STARRED_LABEL_ID) if (labelId == ServiceConstants.STARRED_LABEL_ID)
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, true).ConfigureAwait(false); pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsFlagged: true));
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false); QueueAssignmentChange(
pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(messageId, labelId),
shouldCreate: true);
} }
} }
private async Task HandleLabelRemovalAsync(HistoryLabelRemoved removedLabel) private async Task HandleLabelRemovalAsync(
HistoryLabelRemoved removedLabel,
List<MailCopyStateUpdate> pendingStateUpdates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
{ {
var messageId = removedLabel.Message.Id; var messageId = removedLabel.Message.Id;
@@ -1146,20 +1231,23 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// ARCHIVE is a virtual folder - handle it separately // ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID) if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{ {
await HandleUnarchiveAssignmentAsync(messageId).ConfigureAwait(false); await HandleUnarchiveAssignmentAsync(messageId, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
continue; continue;
} }
// When UNREAD label is removed mark the message as read. // When UNREAD label is removed mark the message as read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID) if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false); pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsRead: true));
// When STARRED label is removed mark the message as un-flagged. // When STARRED label is removed mark the message as un-flagged.
if (labelId == ServiceConstants.STARRED_LABEL_ID) if (labelId == ServiceConstants.STARRED_LABEL_ID)
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, false).ConfigureAwait(false); pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsFlagged: false));
// For other labels remove the mail assignment. QueueAssignmentChange(
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false); pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(messageId, labelId),
shouldCreate: false);
} }
} }
@@ -1542,6 +1630,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
await Task.WhenAll(batchTasks).ConfigureAwait(false); await Task.WhenAll(batchTasks).ConfigureAwait(false);
// Process all downloaded messages // Process all downloaded messages
var pendingPackages = new List<NewMailItemPackage>();
foreach (var gmailMessage in downloadedMessages) foreach (var gmailMessage in downloadedMessages)
{ {
try try
@@ -1552,12 +1642,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false); var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null) if (packages != null)
{ pendingPackages.AddRange(packages);
foreach (var package in packages)
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
}
// Update sync identifier if available // Update sync identifier if available
if (gmailMessage.HistoryId.HasValue) if (gmailMessage.HistoryId.HasValue)
@@ -1570,6 +1655,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
_logger.Error(ex, "Failed to process downloaded message {MessageId}", gmailMessage.Id); _logger.Error(ex, "Failed to process downloaded message {MessageId}", gmailMessage.Id);
} }
} }
if (pendingPackages.Count > 0)
{
await _gmailChangeProcessor.CreateMailsAsync(Account.Id, pendingPackages).ConfigureAwait(false);
}
} }
} }
@@ -1592,12 +1682,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Create mail packages from metadata // Create mail packages from metadata
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false); var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null) if (packages != null && packages.Count > 0)
{ {
foreach (var package in packages) await _gmailChangeProcessor.CreateMailsAsync(Account.Id, packages).ConfigureAwait(false);
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
} }
// Update sync identifier if available // Update sync identifier if available
@@ -1897,9 +1984,19 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (historyId == null) return false; if (historyId == null) return false;
var newHistoryId = historyId.Value; var newHistoryId = historyId.Value;
var currentSynchronizationIdentifier = Account.SynchronizationDeltaIdentifier;
return Account.SynchronizationDeltaIdentifier == null || if (string.IsNullOrWhiteSpace(currentSynchronizationIdentifier))
(ulong.TryParse(Account.SynchronizationDeltaIdentifier, out ulong currentIdentifier) && newHistoryId > currentIdentifier); return true;
if (!ulong.TryParse(currentSynchronizationIdentifier, out ulong currentIdentifier))
{
_logger.Warning("Current Gmail history ID '{HistoryId}' is invalid for {Name}. Replacing it with {NewHistoryId}.",
currentSynchronizationIdentifier, Account.Name, newHistoryId);
return true;
}
return newHistoryId > currentIdentifier;
} }
private async Task UpdateAccountSyncIdentifierAsync(ulong? historyId) private async Task UpdateAccountSyncIdentifierAsync(ulong? historyId)
@@ -1932,12 +2029,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Create mail packages from the downloaded message // Create mail packages from the downloaded message
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false); var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null) if (packages != null && packages.Count > 0)
{ {
foreach (var package in packages) await _gmailChangeProcessor.CreateMailsAsync(Account.Id, packages).ConfigureAwait(false);
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
} }
await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId).ConfigureAwait(false); await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId).ConfigureAwait(false);
@@ -1989,25 +2083,27 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
switch (bundle.UIChangeRequest) switch (bundle.UIChangeRequest)
{ {
case BatchMarkReadRequest batchMarkReadRequest: case BatchMarkReadRequest batchMarkReadRequest:
foreach (var request in batchMarkReadRequest) await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
{ batchMarkReadRequest.Select(request => new MailCopyStateUpdate(request.Item.Id, IsRead: request.IsRead)))
await _gmailChangeProcessor.ChangeMailReadStatusAsync(request.Item.Id, request.IsRead).ConfigureAwait(false); .ConfigureAwait(false);
}
break; break;
case MarkReadRequest markReadRequest: case MarkReadRequest markReadRequest:
await _gmailChangeProcessor.ChangeMailReadStatusAsync(markReadRequest.Item.Id, markReadRequest.IsRead).ConfigureAwait(false); await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
[new MailCopyStateUpdate(markReadRequest.Item.Id, IsRead: markReadRequest.IsRead)])
.ConfigureAwait(false);
break; break;
case BatchChangeFlagRequest batchChangeFlagRequest: case BatchChangeFlagRequest batchChangeFlagRequest:
foreach (var request in batchChangeFlagRequest) await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
{ batchChangeFlagRequest.Select(request => new MailCopyStateUpdate(request.Item.Id, IsFlagged: request.IsFlagged)))
await _gmailChangeProcessor.ChangeFlagStatusAsync(request.Item.Id, request.IsFlagged).ConfigureAwait(false); .ConfigureAwait(false);
}
break; break;
case ChangeFlagRequest changeFlagRequest: case ChangeFlagRequest changeFlagRequest:
await _gmailChangeProcessor.ChangeFlagStatusAsync(changeFlagRequest.Item.Id, changeFlagRequest.IsFlagged).ConfigureAwait(false); await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
[new MailCopyStateUpdate(changeFlagRequest.Item.Id, IsFlagged: changeFlagRequest.IsFlagged)])
.ConfigureAwait(false);
break; break;
} }
} }
@@ -2065,16 +2161,31 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
} }
var existingAfterDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false); var existingAfterDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false);
var pendingArchiveCreates = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
var pendingArchiveDeletes = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
foreach (var archiveAddedItem in existingAfterDownload) foreach (var archiveAddedItem in existingAfterDownload)
{ {
await HandleArchiveAssignmentAsync(archiveAddedItem).ConfigureAwait(false); await HandleArchiveAssignmentAsync(archiveAddedItem, pendingArchiveCreates, pendingArchiveDeletes).ConfigureAwait(false);
}
if (pendingArchiveCreates.Count > 0)
{
await _gmailChangeProcessor.CreateAssignmentsAsync(Account.Id, pendingArchiveCreates.Values.ToList()).ConfigureAwait(false);
} }
} }
var pendingArchiveRemovals = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
var pendingArchiveCreateOverrides = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
foreach (var unAarchivedRemovedItem in removedArchiveIds) foreach (var unAarchivedRemovedItem in removedArchiveIds)
{ {
await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem).ConfigureAwait(false); await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem, pendingArchiveCreateOverrides, pendingArchiveRemovals).ConfigureAwait(false);
}
if (pendingArchiveRemovals.Count > 0)
{
await _gmailChangeProcessor.DeleteAssignmentsAsync(Account.Id, pendingArchiveRemovals.Values.ToList()).ConfigureAwait(false);
} }
} }
+54 -6
View File
@@ -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;
@@ -20,6 +21,8 @@ namespace Wino.Mail.ViewModels.Collections;
public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsChangedMessage> public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsChangedMessage>
{ {
private const int UiMutationBatchSize = 40;
// We cache each mail copy id for faster access on updates. // We cache each mail copy id for faster access on updates.
// If the item provider here for update or removal doesn't exist here // If the item provider here for update or removal doesn't exist here
// we can ignore the operation. // we can ignore the operation.
@@ -139,10 +142,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 +625,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;
@@ -748,18 +765,21 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
// Execute all updates in a single UI thread call // Execute all updates in a single UI thread call
if (itemsToUpdate.Count > 0) if (itemsToUpdate.Count > 0)
{ {
await ExecuteUIThread(() => foreach (var updateBatch in itemsToUpdate.Chunk(UiMutationBatchSize))
{ {
foreach (var (existing, updated) in itemsToUpdate) await ExecuteUIThread(() =>
{ {
UpdateUniqueIdHashes(existing, false); foreach (var (existing, updated) in updateBatch)
existing.UpdateFrom(updated); {
UpdateUniqueIdHashes(existing, true); UpdateUniqueIdHashes(existing, false);
} existing.UpdateFrom(updated);
}); UpdateUniqueIdHashes(existing, true);
}
});
}
} }
// Group items by their grouping key and add them in a single UI thread call // Group items by their grouping key and add them in bounded UI thread calls.
if (itemsToAdd.Count > 0) if (itemsToAdd.Count > 0)
{ {
var groupedItems = await Task.Run(() => itemsToAdd var groupedItems = await Task.Run(() => itemsToAdd
@@ -772,32 +792,35 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
}) })
.ToList()).ConfigureAwait(false); .ToList()).ConfigureAwait(false);
await ExecuteUIThread(() => foreach (var groupedItem in groupedItems)
{ {
foreach (var groupedItem in groupedItems) var groupKey = groupedItem.Key;
var groupItems = groupedItem.Items;
foreach (var groupBatch in groupItems.Chunk(UiMutationBatchSize))
{ {
var groupKey = groupedItem.Key; await ExecuteUIThread(() =>
var groupItems = groupedItem.Items;
// Update caches first
foreach (var item in groupItems)
{ {
UpdateUniqueIdHashes(item, true); // Update caches first so lookup helpers remain consistent during inserts.
UpdateThreadIdCache(item, true); foreach (var item in groupBatch)
}
foreach (var item in groupItems)
{
_mailItemSource.InsertItem(groupKey, listComparer, item, listComparer);
var targetGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
if (targetGroup != null)
{ {
_itemToGroupMap[item] = targetGroup; UpdateUniqueIdHashes(item, true);
UpdateThreadIdCache(item, true);
} }
}
foreach (var item in groupBatch)
{
_mailItemSource.InsertItem(groupKey, listComparer, item, listComparer);
var targetGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
if (targetGroup != null)
{
_itemToGroupMap[item] = targetGroup;
}
}
});
} }
}); }
} }
} }
@@ -957,20 +980,23 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
if (updates.Count == 0) if (updates.Count == 0)
return; return;
await ExecuteUIThread(() => foreach (var updateBatch in updates.Chunk(UiMutationBatchSize))
{ {
foreach (var update in updates) await ExecuteUIThread(() =>
{ {
var existingItem = update.ItemContainer.ItemViewModel; foreach (var update in updateBatch)
var appliedChanges = existingItem.ApplyStateChanges(update.UpdatedState.IsRead, update.UpdatedState.IsFlagged);
existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
if (update.ItemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None)
{ {
update.ItemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges); var existingItem = update.ItemContainer.ItemViewModel;
var appliedChanges = existingItem.ApplyStateChanges(update.UpdatedState.IsRead, update.UpdatedState.IsFlagged);
existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
if (update.ItemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None)
{
update.ItemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges);
}
} }
} });
}); }
} }
private async Task UpdateMailCopiesInternalAsync(IEnumerable<MailCopy> updatedMailCopies, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties) private async Task UpdateMailCopiesInternalAsync(IEnumerable<MailCopy> updatedMailCopies, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties)
@@ -993,22 +1019,35 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
if (updates.Count == 0) if (updates.Count == 0)
return; return;
await ExecuteUIThread(() => if (changedProperties == MailCopyChangeFlags.None || ShouldReinsertForChanges(changedProperties))
{ {
foreach (var update in updates) foreach (var update in updates)
{ {
var updatedMail = update.UpdatedMail; await UpdateExistingItemAsync(update.ItemContainer, update.UpdatedMail, mailUpdateSource, changedProperties);
var itemContainer = update.ItemContainer;
var existingItem = itemContainer.ItemViewModel;
var appliedChanges = existingItem.UpdateFrom(updatedMail, changedProperties);
existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
if (itemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None)
{
itemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges);
}
} }
});
return;
}
foreach (var updateBatch in updates.Chunk(UiMutationBatchSize))
{
await ExecuteUIThread(() =>
{
foreach (var update in updateBatch)
{
var updatedMail = update.UpdatedMail;
var itemContainer = update.ItemContainer;
var existingItem = itemContainer.ItemViewModel;
var appliedChanges = existingItem.UpdateFrom(updatedMail, changedProperties);
existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
if (itemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None)
{
itemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges);
}
}
});
}
} }
public MailItemViewModel GetFirst() => AllItems.ElementAtOrDefault(0); public MailItemViewModel GetFirst() => AllItems.ElementAtOrDefault(0);
@@ -10,6 +10,7 @@ using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using MimeKit; using MimeKit;
using MimeKit.Cryptography; using MimeKit.Cryptography;
using Serilog;
using Wino.Core.Domain; 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;
@@ -37,9 +38,11 @@ public partial class ComposePageViewModel : MailBaseViewModel,
public event EventHandler CloseRequested; public event EventHandler CloseRequested;
private static readonly TimeSpan LocalDraftRetryGracePeriod = TimeSpan.FromSeconds(15); private static readonly TimeSpan LocalDraftRetryGracePeriod = TimeSpan.FromSeconds(15);
private const string MimeFileName = "mail.eml";
public Func<Task<string>> GetHTMLBodyFunction; public Func<Task<string>> GetHTMLBodyFunction;
public Func<string, Task> RenderHtmlBodyAsyncFunc { get; set; } public Func<string, Task> RenderHtmlBodyAsyncFunc { get; set; }
public Func<string, Task<bool>> SaveHTMLasPDFFunc { get; set; }
public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args) public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args)
{ {
@@ -440,6 +443,77 @@ public partial class ComposePageViewModel : MailBaseViewModel,
await _mimeFileService.SaveMimeMessageAsync(CurrentMailDraftItem.MailCopy.FileId, CurrentMimeMessage, ComposingAccount.Id).ConfigureAwait(false); await _mimeFileService.SaveMimeMessageAsync(CurrentMailDraftItem.MailCopy.FileId, CurrentMimeMessage, ComposingAccount.Id).ConfigureAwait(false);
} }
public async Task ExportAsPdfAsync()
{
if (SaveHTMLasPDFFunc == null)
{
return;
}
try
{
var pickedFilePath = await _dialogService.PickFilePathAsync($"{GetSuggestedExportFileName()}.pdf").ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(pickedFilePath))
{
return;
}
bool isSaved = await SaveHTMLasPDFFunc(pickedFilePath).ConfigureAwait(false);
if (isSaved)
{
_dialogService.InfoBarMessage(
Translator.Info_PDFSaveSuccessTitle,
string.Format(Translator.Info_PDFSaveSuccessMessage, pickedFilePath),
InfoBarMessageType.Success);
}
}
catch (Exception ex)
{
Log.Error(ex, "Failed to save compose draft as PDF.");
_dialogService.InfoBarMessage(Translator.Info_PDFSaveFailedTitle, ex.Message, InfoBarMessageType.Error);
}
}
public async Task ExportAsEmlAsync()
{
if (CurrentMailDraftItem?.MailCopy == null || ComposingAccount == null)
{
_dialogService.InfoBarMessage(Translator.Info_ComposerMissingMIMETitle, Translator.Info_ComposerMissingMIMEMessage, InfoBarMessageType.Error);
return;
}
try
{
await UpdateMimeChangesAsync().ConfigureAwait(false);
var pickedFilePath = await _dialogService.PickFilePathAsync($"{GetSuggestedExportFileName()}.eml").ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(pickedFilePath))
{
return;
}
var resourcePath = await _mimeFileService
.GetMimeResourcePathAsync(ComposingAccount.Id, CurrentMailDraftItem.MailCopy.FileId)
.ConfigureAwait(false);
var sourceFilePath = Path.Combine(resourcePath, MimeFileName);
File.Copy(sourceFilePath, pickedFilePath, true);
_dialogService.InfoBarMessage(
Translator.Info_EMLSaveSuccessTitle,
string.Format(Translator.Info_EMLSaveSuccessMessage, pickedFilePath),
InfoBarMessageType.Success);
}
catch (Exception ex)
{
Log.Error(ex, "Failed to save compose draft as EML.");
_dialogService.InfoBarMessage(Translator.Info_EMLSaveFailedTitle, ex.Message, InfoBarMessageType.Error);
}
}
private async Task UpdateMailCopyAsync() private async Task UpdateMailCopyAsync()
{ {
CurrentMailDraftItem.Subject = CurrentMimeMessage.Subject; CurrentMailDraftItem.Subject = CurrentMimeMessage.Subject;
@@ -879,6 +953,29 @@ public partial class ComposePageViewModel : MailBaseViewModel,
list.Add(new MailboxAddress(item.Name, item.Address)); list.Add(new MailboxAddress(item.Name, item.Address));
} }
private string GetSuggestedExportFileName()
{
var subject = string.IsNullOrWhiteSpace(Subject) ? Translator.MailItemNoSubject : Subject;
return SanitizeFileNamePart(subject);
}
private static string SanitizeFileNamePart(string value)
{
var invalidCharacters = Path.GetInvalidFileNameChars();
var sanitizedChars = value.Trim().ToCharArray();
for (var i = 0; i < sanitizedChars.Length; i++)
{
if (Array.IndexOf(invalidCharacters, sanitizedChars[i]) >= 0)
{
sanitizedChars[i] = '_';
}
}
var sanitized = new string(sanitizedChars).Trim();
return string.IsNullOrWhiteSpace(sanitized) ? "email" : sanitized;
}
private async Task<(DraftCreationReason reason, MailCopy referenceMailCopy)> ResolveRetryDraftContextAsync() private async Task<(DraftCreationReason reason, MailCopy referenceMailCopy)> ResolveRetryDraftContextAsync()
{ {
if (CurrentMimeMessage == null || CurrentMailDraftItem?.MailCopy?.AssignedAccount == null) if (CurrentMimeMessage == null || CurrentMailDraftItem?.MailCopy?.AssignedAccount == null)
@@ -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,
+43 -3
View File
@@ -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;
+79 -3
View File
@@ -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;
@@ -216,6 +217,8 @@ public partial class MailListPageViewModel : MailBaseViewModel,
{ {
base.OnNavigatedTo(mode, parameters); base.OnNavigatedTo(mode, parameters);
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
PreferencesService.PreferenceChanged += PreferencesServiceChanged;
MailCollection.ItemSelectionChanged += MailItemSelectionChanged; MailCollection.ItemSelectionChanged += MailItemSelectionChanged;
} }
@@ -223,6 +226,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
{ {
base.OnNavigatedFrom(mode, parameters); base.OnNavigatedFrom(mode, parameters);
PreferencesService.PreferenceChanged -= PreferencesServiceChanged;
MailCollection.ItemSelectionChanged -= MailItemSelectionChanged; MailCollection.ItemSelectionChanged -= MailItemSelectionChanged;
await MailCollection.ClearAsync(); await MailCollection.ClearAsync();
@@ -295,7 +299,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
public bool IsJunkFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Junk; public bool IsJunkFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Junk;
public bool IsCategoryView => ActiveFolder is IMailCategoryMenuItem or IMergedMailCategoryMenuItem; public bool IsCategoryView => ActiveFolder is IMailCategoryMenuItem or IMergedMailCategoryMenuItem;
public bool IsSyncButtonVisible => !IsCategoryView; public bool IsSyncButtonVisible => !IsCategoryView;
public bool IsEmptyFolderButtonVisible => IsJunkFolder; public bool IsEmptyFolderButtonVisible => IsJunkFolder && PreferencesService.IsShowEmptyJunkFolderEnabled;
public string SelectedMessageText => IsDragInProgress public string SelectedMessageText => IsDragInProgress
? string.Format(Translator.MailsDragging, DraggingItemsCount) ? string.Format(Translator.MailsDragging, DraggingItemsCount)
@@ -385,6 +389,18 @@ public partial class MailListPageViewModel : MailBaseViewModel,
OnPropertyChanged(nameof(IsFolderEmpty)); OnPropertyChanged(nameof(IsFolderEmpty));
} }
private async void PreferencesServiceChanged(object sender, string propertyName)
{
if (propertyName != nameof(IPreferencesService.IsShowEmptyJunkFolderEnabled))
return;
await ExecuteUIThread(() =>
{
OnPropertyChanged(nameof(IsEmptyFolderButtonVisible));
EmptyFolderCommand.NotifyCanExecuteChanged();
});
}
private async void UpdateBarMessage(InfoBarMessageType severity, string title, string message) private async void UpdateBarMessage(InfoBarMessageType severity, string title, string message)
{ {
await ExecuteUIThread(() => await ExecuteUIThread(() =>
@@ -587,7 +603,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
} }
} }
private bool CanEmptyFolder() => IsJunkFolder && !IsAccountSynchronizerInSynchronization; private bool CanEmptyFolder() => IsEmptyFolderButtonVisible && !IsAccountSynchronizerInSynchronization;
[RelayCommand(CanExecute = nameof(CanLoadMoreItems))] [RelayCommand(CanExecute = nameof(CanLoadMoreItems))]
private async Task LoadMoreItemsAsync() private async Task LoadMoreItemsAsync()
@@ -760,6 +776,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 +1344,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 +1359,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 +1614,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",
@@ -20,47 +20,18 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
IAccountService accountService) : MailBaseViewModel IAccountService accountService) : MailBaseViewModel
{ {
public ObservableCollection<AccountSignature> Signatures { get; set; } = []; public ObservableCollection<AccountSignature> Signatures { get; set; } = [];
private bool isLoaded;
[ObservableProperty] [ObservableProperty]
private bool isSignatureEnabled; public partial bool IsSignatureEnabled { get; set; }
private int signatureForNewMessagesIndex;
public Guid EmptyGuid { get; } = Guid.Empty; public Guid EmptyGuid { get; } = Guid.Empty;
public int SignatureForNewMessagesIndex [ObservableProperty]
{ public partial AccountSignature SelectedSignatureForNewMessages { get; set; }
get => signatureForNewMessagesIndex;
set
{
if (value == -1)
{
SetProperty(ref signatureForNewMessagesIndex, 0);
}
else
{
SetProperty(ref signatureForNewMessagesIndex, value);
}
}
}
private int signatureForFollowingMessagesIndex; [ObservableProperty]
public partial AccountSignature SelectedSignatureForFollowingMessages { get; set; }
public int SignatureForFollowingMessagesIndex
{
get => signatureForFollowingMessagesIndex;
set
{
if (value == -1)
{
SetProperty(ref signatureForFollowingMessagesIndex, 0);
}
else
{
SetProperty(ref signatureForFollowingMessagesIndex, value);
}
}
}
private MailAccount Account { get; set; } private MailAccount Account { get; set; }
@@ -71,6 +42,7 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
public override async void OnNavigatedTo(NavigationMode mode, object parameters) public override async void OnNavigatedTo(NavigationMode mode, object parameters)
{ {
base.OnNavigatedTo(mode, parameters); base.OnNavigatedTo(mode, parameters);
isLoaded = false;
if (parameters is Guid accountId) if (parameters is Guid accountId)
Account = await _accountService.GetAccountAsync(accountId); Account = await _accountService.GetAccountAsync(accountId);
@@ -78,36 +50,43 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
if (Account == null) return; if (Account == null) return;
var dbSignatures = await _signatureService.GetSignaturesAsync(Account.Id); var dbSignatures = await _signatureService.GetSignaturesAsync(Account.Id);
IsSignatureEnabled = Account.Preferences.IsSignatureEnabled; var noneSignature = new AccountSignature { Id = EmptyGuid, Name = Translator.SettingsSignature_NoneSignatureName };
var signatureForNewMessages = dbSignatures.FirstOrDefault(x => x.Id == Account.Preferences.SignatureIdForNewMessages) ?? noneSignature;
var signatureForFollowingMessages = dbSignatures.FirstOrDefault(x => x.Id == Account.Preferences.SignatureIdForFollowingMessages) ?? noneSignature;
Signatures.Clear(); await ExecuteUIThread(() =>
Signatures.Add(new AccountSignature { Id = EmptyGuid, Name = Translator.SettingsSignature_NoneSignatureName }); {
dbSignatures.ForEach(Signatures.Add); IsSignatureEnabled = Account.Preferences.IsSignatureEnabled;
SignatureForNewMessagesIndex = Signatures.IndexOf(Signatures.FirstOrDefault(x => x.Id == Account.Preferences.SignatureIdForNewMessages)); Signatures.Clear();
SignatureForFollowingMessagesIndex = Signatures.IndexOf(Signatures.FirstOrDefault(x => x.Id == Account.Preferences.SignatureIdForFollowingMessages)); Signatures.Add(noneSignature);
dbSignatures.ForEach(Signatures.Add);
SelectedSignatureForNewMessages = signatureForNewMessages;
SelectedSignatureForFollowingMessages = signatureForFollowingMessages;
});
isLoaded = true;
} }
protected override async void OnPropertyChanged(PropertyChangedEventArgs e) protected override async void OnPropertyChanged(PropertyChangedEventArgs e)
{ {
base.OnPropertyChanged(e); base.OnPropertyChanged(e);
if (!isLoaded || Account?.Preferences == null) return;
switch (e.PropertyName) switch (e.PropertyName)
{ {
case nameof(IsSignatureEnabled): case nameof(IsSignatureEnabled):
Account.Preferences.IsSignatureEnabled = IsSignatureEnabled; Account.Preferences.IsSignatureEnabled = IsSignatureEnabled;
await _accountService.UpdateAccountAsync(Account); await _accountService.UpdateAccountAsync(Account);
break; break;
case nameof(SignatureForNewMessagesIndex): case nameof(SelectedSignatureForNewMessages):
Account.Preferences.SignatureIdForNewMessages = SignatureForNewMessagesIndex > -1 Account.Preferences.SignatureIdForNewMessages = GetPersistedSignatureId(SelectedSignatureForNewMessages);
&& Signatures[SignatureForNewMessagesIndex].Id != EmptyGuid
? Signatures[SignatureForNewMessagesIndex].Id : null;
await _accountService.UpdateAccountAsync(Account); await _accountService.UpdateAccountAsync(Account);
break; break;
case nameof(SignatureForFollowingMessagesIndex): case nameof(SelectedSignatureForFollowingMessages):
Account.Preferences.SignatureIdForFollowingMessages = SignatureForFollowingMessagesIndex > -1 Account.Preferences.SignatureIdForFollowingMessages = GetPersistedSignatureId(SelectedSignatureForFollowingMessages);
&& Signatures[SignatureForFollowingMessagesIndex].Id != EmptyGuid
? Signatures[SignatureForFollowingMessagesIndex].Id : null;
await _accountService.UpdateAccountAsync(Account); await _accountService.UpdateAccountAsync(Account);
break; break;
} }
@@ -121,7 +100,7 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
if (dialogResult == null) return; if (dialogResult == null) return;
dialogResult.MailAccountId = Account.Id; dialogResult.MailAccountId = Account.Id;
Signatures.Add(dialogResult); await ExecuteUIThread(() => Signatures.Add(dialogResult));
await _signatureService.CreateSignatureAsync(dialogResult); await _signatureService.CreateSignatureAsync(dialogResult);
} }
@@ -133,18 +112,23 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
if (dialogResult == null) return; if (dialogResult == null) return;
var indexOfCurrentSignature = Signatures.IndexOf(signatureModel); var indexOfCurrentSignature = Signatures.IndexOf(signatureModel);
var signatureNewMessagesIndex = SignatureForNewMessagesIndex; if (indexOfCurrentSignature < 0) return;
var signatureFollowingMessagesIndex = SignatureForFollowingMessagesIndex;
Signatures[indexOfCurrentSignature] = dialogResult; var wasSelectedForNewMessages = SelectedSignatureForNewMessages?.Id == signatureModel.Id;
var wasSelectedForFollowingMessages = SelectedSignatureForFollowingMessages?.Id == signatureModel.Id;
// Reset selection to point updated signature. dialogResult.MailAccountId = signatureModel.MailAccountId;
// When Item updated/removed index switches to -1. We save index that was used before and update -1 to it.
if (signatureNewMessagesIndex == indexOfCurrentSignature)
SignatureForNewMessagesIndex = indexOfCurrentSignature;
if (signatureFollowingMessagesIndex == indexOfCurrentSignature) await ExecuteUIThread(() =>
SignatureForFollowingMessagesIndex = indexOfCurrentSignature; {
Signatures[indexOfCurrentSignature] = dialogResult;
if (wasSelectedForNewMessages)
SelectedSignatureForNewMessages = dialogResult;
if (wasSelectedForFollowingMessages)
SelectedSignatureForFollowingMessages = dialogResult;
});
await _signatureService.UpdateSignatureAsync(dialogResult); await _signatureService.UpdateSignatureAsync(dialogResult);
} }
@@ -156,7 +140,31 @@ public partial class SignatureManagementPageViewModel(IMailDialogService dialogS
if (!shouldRemove) return; if (!shouldRemove) return;
Signatures.Remove(signatureModel); var shouldResetNewMessagesSignature = SelectedSignatureForNewMessages?.Id == signatureModel.Id;
var shouldResetFollowingMessagesSignature = SelectedSignatureForFollowingMessages?.Id == signatureModel.Id;
await ExecuteUIThread(() =>
{
Signatures.Remove(signatureModel);
var noneSignature = GetNoneSignature();
if (shouldResetNewMessagesSignature)
SelectedSignatureForNewMessages = noneSignature;
if (shouldResetFollowingMessagesSignature)
SelectedSignatureForFollowingMessages = noneSignature;
});
await _signatureService.DeleteSignatureAsync(signatureModel); await _signatureService.DeleteSignatureAsync(signatureModel);
} }
private Guid? GetPersistedSignatureId(AccountSignature signature)
=> signature?.Id is Guid signatureId && signatureId != EmptyGuid
? signatureId
: null;
private AccountSignature GetNoneSignature()
=> Signatures.FirstOrDefault(x => x.Id == EmptyGuid)
?? new AccountSignature { Id = EmptyGuid, Name = Translator.SettingsSignature_NoneSignatureName };
} }
@@ -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,
+2
View File
@@ -479,6 +479,8 @@ public partial class App : WinoApplication,
EnsureAppNotificationRegistration(); EnsureAppNotificationRegistration();
await TranslationService.InitializeAsync();
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>() await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
.RunIfNeededAsync(); .RunIfNeededAsync();
@@ -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>
@@ -1,5 +1,4 @@
using System; using System;
using System.Text.RegularExpressions;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
@@ -24,9 +23,6 @@ public sealed partial class SignatureEditorDialog : ContentDialog
{ {
InitializeComponent(); InitializeComponent();
SignatureNameTextBox.Text = signatureModel.Name.Trim();
SignatureNameTextBox.Header = string.Format(Translator.SignatureEditorDialog_SignatureName_TitleEdit, signatureModel.Name);
Result = new AccountSignature Result = new AccountSignature
{ {
Id = signatureModel.Id, Id = signatureModel.Id,
@@ -35,6 +31,9 @@ public sealed partial class SignatureEditorDialog : ContentDialog
HtmlBody = signatureModel.HtmlBody HtmlBody = signatureModel.HtmlBody
}; };
SignatureNameTextBox.Text = Result.Name.Trim();
SignatureNameTextBox.Header = string.Format(Translator.SignatureEditorDialog_SignatureName_TitleEdit, Result.Name);
// TODO: Should be added additional logic to enable/disable primary button when webview content changed. // TODO: Should be added additional logic to enable/disable primary button when webview content changed.
IsPrimaryButtonEnabled = true; IsPrimaryButtonEnabled = true;
} }
@@ -51,7 +50,7 @@ public sealed partial class SignatureEditorDialog : ContentDialog
private async void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) private async void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{ {
var newSignature = Regex.Unescape((await WebViewEditor.GetHtmlBodyAsync())!); var newSignature = await WebViewEditor.GetHtmlBodyAsync() ?? string.Empty;
if (Result == null) if (Result == null)
{ {
+15
View File
@@ -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;
+1
View File
@@ -9,6 +9,7 @@ const joditConfig = {
"showWordsCounter": false, "showWordsCounter": false,
"showXPathInStatusbar": false, "showXPathInStatusbar": false,
"spellcheck": true, "spellcheck": true,
"defaultActionOnPaste": "insert_as_text",
"link": { "link": {
"processVideoLink": false "processVideoLink": false
}, },
+1 -1
View File
@@ -23,7 +23,7 @@
<Identity <Identity
Name="58272BurakKSE.WinoMailPreview" Name="58272BurakKSE.WinoMailPreview"
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911" Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
Version="2.0.4.0" /> Version="2.0.7.0" />
<mp:PhoneIdentity PhoneProductId="7b7e90e9-cc55-4409-9769-99b4b5ed6e9b" PhonePublisherId="00000000-0000-0000-0000-000000000000"/> <mp:PhoneIdentity PhoneProductId="7b7e90e9-cc55-4409-9769-99b4b5ed6e9b" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
@@ -9,6 +9,9 @@ namespace Wino.Mail.WinUI.Services;
public class ConfigurationService : IConfigurationService public class ConfigurationService : IConfigurationService
{ {
public bool Contains(string key)
=> ApplicationData.Current.LocalSettings.Values.ContainsKey(key);
public T Get<T>(string key, T defaultValue = default!) public T Get<T>(string key, T defaultValue = default!)
=> GetInternal(key, ApplicationData.Current.LocalSettings.Values, defaultValue); => GetInternal(key, ApplicationData.Current.LocalSettings.Values, defaultValue);
@@ -233,6 +233,17 @@ public class NotificationBuilder : INotificationBuilder
ShowNotification(builder, "store-update-available"); ShowNotification(builder, "store-update-available");
} }
public void CreateReleaseMigrationNotification()
{
var builder = CreateBuilder();
builder.AddText(Translator.Notifications_ReleaseMigrationTitle);
builder.AddText(Translator.Notifications_ReleaseMigrationMessage);
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
builder.AddButton(CreateDismissButton());
ShowNotification(builder, "release-migration-v2");
}
public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds) public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds)
{ {
if (calendarItem == null) if (calendarItem == null)
@@ -111,6 +111,12 @@ public class PreferencesService(IConfigurationService configurationService) : Ob
set => SetPropertyAndSave(nameof(IsHardDeleteProtectionEnabled), value); set => SetPropertyAndSave(nameof(IsHardDeleteProtectionEnabled), value);
} }
public bool IsShowEmptyJunkFolderEnabled
{
get => _configurationService.Get(nameof(IsShowEmptyJunkFolderEnabled), false);
set => SetPropertyAndSave(nameof(IsShowEmptyJunkFolderEnabled), value);
}
public bool IsThreadingEnabled public bool IsThreadingEnabled
{ {
get => _configurationService.Get(nameof(IsThreadingEnabled), true); get => _configurationService.Get(nameof(IsThreadingEnabled), true);
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Serilog; using Serilog;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
@@ -14,13 +15,16 @@ public sealed class ReleaseLocalAccountDataCleanupService
private readonly IConfigurationService _configurationService; private readonly IConfigurationService _configurationService;
private readonly IApplicationConfiguration _applicationConfiguration; private readonly IApplicationConfiguration _applicationConfiguration;
private readonly INotificationBuilder _notificationBuilder;
private readonly ILogger _logger = Log.ForContext<ReleaseLocalAccountDataCleanupService>(); private readonly ILogger _logger = Log.ForContext<ReleaseLocalAccountDataCleanupService>();
public ReleaseLocalAccountDataCleanupService(IConfigurationService configurationService, public ReleaseLocalAccountDataCleanupService(IConfigurationService configurationService,
IApplicationConfiguration applicationConfiguration) IApplicationConfiguration applicationConfiguration,
INotificationBuilder notificationBuilder)
{ {
_configurationService = configurationService; _configurationService = configurationService;
_applicationConfiguration = applicationConfiguration; _applicationConfiguration = applicationConfiguration;
_notificationBuilder = notificationBuilder;
} }
public async Task RunIfNeededAsync() public async Task RunIfNeededAsync()
@@ -45,44 +49,70 @@ public sealed class ReleaseLocalAccountDataCleanupService
Path.Combine(publisherPath, LegacyDatabaseFileName) Path.Combine(publisherPath, LegacyDatabaseFileName)
}; };
var hadLegacyData = false;
foreach (var targetPath in cleanupTargets) foreach (var targetPath in cleanupTargets)
{ {
await DeletePathIfExistsAsync(localFolderPath, targetPath).ConfigureAwait(false); hadLegacyData |= await DeletePathIfExistsAsync(targetPath, localFolderPath, publisherPath).ConfigureAwait(false);
} }
_configurationService.Set(CleanupCompletedSettingKey, true); _configurationService.Set(CleanupCompletedSettingKey, true);
if (hadLegacyData)
{
_notificationBuilder.CreateReleaseMigrationNotification();
}
_logger.Information("Completed one-time local account data cleanup for release migration."); _logger.Information("Completed one-time local account data cleanup for release migration.");
} }
private async Task DeletePathIfExistsAsync(string localFolderPath, string targetPath) private async Task<bool> DeletePathIfExistsAsync(string targetPath, params string[] allowedRootPaths)
{ {
try try
{ {
var fullTargetPath = Path.GetFullPath(targetPath); var fullTargetPath = Path.GetFullPath(targetPath);
var fullLocalFolderPath = Path.GetFullPath(localFolderPath); if (!allowedRootPaths.Any(rootPath => IsPathUnderAllowedRoot(fullTargetPath, rootPath)))
if (!fullTargetPath.StartsWith(fullLocalFolderPath, StringComparison.OrdinalIgnoreCase))
{ {
_logger.Warning("Skipped startup cleanup for path outside local folder: {TargetPath}", fullTargetPath); _logger.Warning("Skipped startup cleanup for path outside allowed roots: {TargetPath}", fullTargetPath);
return; return false;
} }
var targetExists = Directory.Exists(fullTargetPath) || File.Exists(fullTargetPath);
if (Directory.Exists(fullTargetPath)) if (Directory.Exists(fullTargetPath))
{ {
await Task.Run(() => Directory.Delete(fullTargetPath, recursive: true)).ConfigureAwait(false); await Task.Run(() => Directory.Delete(fullTargetPath, recursive: true)).ConfigureAwait(false);
_logger.Information("Deleted legacy startup cleanup directory {TargetPath}", fullTargetPath); _logger.Information("Deleted legacy startup cleanup directory {TargetPath}", fullTargetPath);
return; return true;
} }
if (File.Exists(fullTargetPath)) if (File.Exists(fullTargetPath))
{ {
File.Delete(fullTargetPath); File.Delete(fullTargetPath);
_logger.Information("Deleted legacy startup cleanup file {TargetPath}", fullTargetPath); _logger.Information("Deleted legacy startup cleanup file {TargetPath}", fullTargetPath);
return true;
} }
return targetExists;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Warning(ex, "Failed to delete legacy startup cleanup path {TargetPath}", targetPath); _logger.Warning(ex, "Failed to delete legacy startup cleanup path {TargetPath}", targetPath);
} }
return false;
}
private static bool IsPathUnderAllowedRoot(string fullTargetPath, string rootPath)
{
if (string.IsNullOrWhiteSpace(rootPath))
return false;
var fullRootPath = Path.GetFullPath(rootPath);
var relativePath = Path.GetRelativePath(fullRootPath, fullTargetPath);
return relativePath != "." &&
!relativePath.StartsWith("..", StringComparison.Ordinal) &&
!Path.IsPathRooted(relativePath);
} }
} }
@@ -185,6 +185,17 @@
<coreControls:WinoFontIcon Icon="{x:Bind GetEditorThemeIcon(WebViewEditor.IsEditorDarkMode), Mode=OneWay}" /> <coreControls:WinoFontIcon Icon="{x:Bind GetEditorThemeIcon(WebViewEditor.IsEditorDarkMode), Mode=OneWay}" />
</AppBarButton.Icon> </AppBarButton.Icon>
</AppBarButton> </AppBarButton>
<AppBarButton Label="{x:Bind domain:Translator.MailOperation_SaveAs}">
<AppBarButton.Icon>
<coreControls:WinoFontIcon Icon="Save" />
</AppBarButton.Icon>
<AppBarButton.Flyout>
<coreControls:WinoMenuFlyout Placement="BottomEdgeAlignedRight">
<MenuFlyoutItem Click="ExportPdf_Click" Text="{x:Bind domain:Translator.Buttons_PDF}" />
<MenuFlyoutItem Click="ExportEml_Click" Text="{x:Bind domain:Translator.Buttons_EML}" />
</coreControls:WinoMenuFlyout>
</AppBarButton.Flyout>
</AppBarButton>
<AppBarButton Command="{x:Bind ViewModel.DiscardCommand}" Label="{x:Bind domain:Translator.Buttons_Discard}"> <AppBarButton Command="{x:Bind ViewModel.DiscardCommand}" Label="{x:Bind domain:Translator.Buttons_Discard}">
<AppBarButton.Icon> <AppBarButton.Icon>
<coreControls:WinoFontIcon Icon="Delete" /> <coreControls:WinoFontIcon Icon="Delete" />
@@ -59,6 +59,17 @@ public sealed partial class ComposePage : ComposePageAbstract,
public ComposePage() public ComposePage()
{ {
InitializeComponent(); InitializeComponent();
ViewModel.SaveHTMLasPDFFunc = async path =>
{
var webView = GetWebView();
if (webView?.CoreWebView2 == null)
{
return false;
}
return await webView.CoreWebView2.PrintToPdfAsync(path, null);
};
ViewModel.CloseRequested += ViewModel_CloseRequested; ViewModel.CloseRequested += ViewModel_CloseRequested;
} }
@@ -495,6 +506,7 @@ public sealed partial class ComposePage : ComposePageAbstract,
FocusManager.GotFocus -= GlobalFocusManagerGotFocus; FocusManager.GotFocus -= GlobalFocusManagerGotFocus;
ComposeAiActionsPanel.CancelPendingOperation(); ComposeAiActionsPanel.CancelPendingOperation();
await ViewModel.UpdateMimeChangesAsync(); await ViewModel.UpdateMimeChangesAsync();
ViewModel.SaveHTMLasPDFFunc = null;
ViewModel.RenderHtmlBodyAsyncFunc = null; ViewModel.RenderHtmlBodyAsyncFunc = null;
DisposeDisposables(); DisposeDisposables();
@@ -565,6 +577,16 @@ public sealed partial class ComposePage : ComposePageAbstract,
} }
} }
private async void ExportPdf_Click(object sender, RoutedEventArgs e)
{
await ViewModel.ExportAsPdfAsync();
}
private async void ExportEml_Click(object sender, RoutedEventArgs e)
{
await ViewModel.ExportAsEmlAsync();
}
protected override void RegisterRecipients() protected override void RegisterRecipients()
{ {
base.RegisterRecipients(); base.RegisterRecipients();
@@ -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)
{ {
} }
} }
@@ -160,6 +160,10 @@
<ToggleSwitch IsOn="{x:Bind ViewModel.PreferencesService.IsHardDeleteProtectionEnabled, Mode=TwoWay}" /> <ToggleSwitch IsOn="{x:Bind ViewModel.PreferencesService.IsHardDeleteProtectionEnabled, Mode=TwoWay}" />
</controls:SettingsCard> </controls:SettingsCard>
<controls:SettingsCard Description="{x:Bind domain:Translator.SettingsEmptyJunkFolderCommand_Description}" Header="{x:Bind domain:Translator.SettingsEmptyJunkFolderCommand_Title}">
<ToggleSwitch IsOn="{x:Bind ViewModel.PreferencesService.IsShowEmptyJunkFolderEnabled, Mode=TwoWay}" />
</controls:SettingsCard>
<controls:SettingsCard Description="{x:Bind domain:Translator.SettingsAutoSelectNextItem_Description}" Header="{x:Bind domain:Translator.SettingsAutoSelectNextItem_Title}"> <controls:SettingsCard Description="{x:Bind domain:Translator.SettingsAutoSelectNextItem_Description}" Header="{x:Bind domain:Translator.SettingsAutoSelectNextItem_Title}">
<ToggleSwitch IsOn="{x:Bind ViewModel.PreferencesService.AutoSelectNextItem, Mode=TwoWay}" /> <ToggleSwitch IsOn="{x:Bind ViewModel.PreferencesService.AutoSelectNextItem, Mode=TwoWay}" />
<controls:SettingsCard.HeaderIcon> <controls:SettingsCard.HeaderIcon>
@@ -77,7 +77,7 @@
IsActionIconVisible="False" IsActionIconVisible="False"
IsClickEnabled="False" IsClickEnabled="False"
IsEnabled="{x:Bind ViewModel.IsSignatureEnabled, Mode=OneWay}"> IsEnabled="{x:Bind ViewModel.IsSignatureEnabled, Mode=OneWay}">
<ComboBox ItemsSource="{x:Bind ViewModel.Signatures}" SelectedIndex="{x:Bind ViewModel.SignatureForNewMessagesIndex, Mode=TwoWay}"> <ComboBox ItemsSource="{x:Bind ViewModel.Signatures}" SelectedItem="{x:Bind ViewModel.SelectedSignatureForNewMessages, Mode=TwoWay}">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="entities:AccountSignature"> <DataTemplate x:DataType="entities:AccountSignature">
<TextBlock Text="{x:Bind Name}" /> <TextBlock Text="{x:Bind Name}" />
@@ -91,7 +91,7 @@
IsActionIconVisible="False" IsActionIconVisible="False"
IsClickEnabled="False" IsClickEnabled="False"
IsEnabled="{x:Bind ViewModel.IsSignatureEnabled, Mode=OneWay}"> IsEnabled="{x:Bind ViewModel.IsSignatureEnabled, Mode=OneWay}">
<ComboBox ItemsSource="{x:Bind ViewModel.Signatures}" SelectedIndex="{x:Bind ViewModel.SignatureForFollowingMessagesIndex, Mode=TwoWay}"> <ComboBox ItemsSource="{x:Bind ViewModel.Signatures}" SelectedItem="{x:Bind ViewModel.SelectedSignatureForFollowingMessages, Mode=TwoWay}">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="entities:AccountSignature"> <DataTemplate x:DataType="entities:AccountSignature">
<TextBlock Text="{x:Bind Name}" /> <TextBlock Text="{x:Bind Name}" />
+61 -7
View File
@@ -147,9 +147,30 @@ public sealed partial class SettingsPage : SettingsPageAbstract,
public void Receive(SettingsRootNavigationRequested message) public void Receive(SettingsRootNavigationRequested message)
{ {
var currentRootPage = SettingsNavigationInfoProvider.GetRootPage(PageHistory.LastOrDefault()?.Request.PageType ?? WinoPage.SettingOptionsPage); var activePage = PageHistory.LastOrDefault()?.Request.PageType ?? WinoPage.SettingOptionsPage;
if (message.PageType != WinoPage.SettingOptionsPage && currentRootPage == message.PageType) var currentRootPage = SettingsNavigationInfoProvider.GetRootPage(activePage);
if (message.PageType == currentRootPage)
{
if (activePage == currentRootPage)
return;
var currentRootIndex = PageHistory
.Select((item, index) => new { item.Request.PageType, Index = index })
.FirstOrDefault(item => item.PageType == currentRootPage)?.Index ?? -1;
if (TryNavigateToBreadcrumbIndex(currentRootIndex))
return;
}
if (message.PageType == WinoPage.SettingOptionsPage)
{
if (activePage == WinoPage.SettingOptionsPage)
return;
NavigateToSettingsHome();
return; return;
}
NavigateToRootPage(message.PageType); NavigateToRootPage(message.PageType);
} }
@@ -196,11 +217,7 @@ public sealed partial class SettingsPage : SettingsPageAbstract,
private void NavigateToRootPage(WinoPage targetPage) private void NavigateToRootPage(WinoPage targetPage)
{ {
PageHistory.Clear(); NavigateToSettingsHome();
SettingsFrame.BackStack.Clear();
SettingsFrame.ForwardStack.Clear();
NavigateBreadcrumb(new BreadcrumbNavigationRequested(Translator.MenuSettings, WinoPage.SettingOptionsPage));
if (targetPage != WinoPage.SettingOptionsPage) if (targetPage != WinoPage.SettingOptionsPage)
{ {
@@ -213,6 +230,43 @@ public sealed partial class SettingsPage : SettingsPageAbstract,
UpdateWindowTitle(); UpdateWindowTitle();
} }
private void NavigateToSettingsHome()
{
if (PageHistory.Count == 0 || SettingsFrame.Content == null)
{
ResetToSettingsHome();
return;
}
if (PageHistory.Count == 1)
return;
if (!TryNavigateToBreadcrumbIndex(0))
{
ResetToSettingsHome();
}
}
private bool TryNavigateToBreadcrumbIndex(int targetIndex)
{
if (!BreadcrumbNavigationHelper.NavigateTo(SettingsFrame, PageHistory, targetIndex))
return false;
UpdateBackNavigationState();
_ = RefreshCurrentPageStateAsync();
UpdateWindowTitle();
return true;
}
private void ResetToSettingsHome()
{
PageHistory.Clear();
SettingsFrame.BackStack.Clear();
SettingsFrame.ForwardStack.Clear();
NavigateBreadcrumb(new BreadcrumbNavigationRequested(Translator.MenuSettings, WinoPage.SettingOptionsPage));
}
public void ResetForModeSwitch() public void ResetForModeSwitch()
{ {
while (PageHistory.Count > 1 && SettingsFrame.CanGoBack) while (PageHistory.Count > 1 && SettingsFrame.CanGoBack)
+184 -2
View File
@@ -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))
+9
View File
@@ -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();
}
} }
+50
View File
@@ -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
};
}
} }
+432 -48
View File
@@ -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);
} }
@@ -703,64 +727,123 @@ public class MailService : BaseDatabaseService, IMailService
public async Task DeleteMailAsync(Guid accountId, string mailCopyId) public async Task DeleteMailAsync(Guid accountId, string mailCopyId)
{ {
var allMails = await GetMailCopiesByIdAsync([mailCopyId]).ConfigureAwait(false); await DeleteMailsAsync(accountId, [mailCopyId]).ConfigureAwait(false);
}
foreach (var mailItem in allMails) public async Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailCopyIds)
{ {
// Delete mime file as well. var targetMailIds = mailCopyIds?
// Even though Gmail might have multiple copies for the same mail, we only have one MIME file for all. .Where(id => !string.IsNullOrWhiteSpace(id))
// Their FileId is inserted same. .Distinct(StringComparer.Ordinal)
.ToList() ?? [];
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false); if (targetMailIds.Count == 0)
} return;
var allMails = await GetMailCopiesByIdAsync(targetMailIds).ConfigureAwait(false);
await DeleteMailCopiesAsync(allMails, preserveMimeFile: false, reportUiChange: true).ConfigureAwait(false);
}
private void ReportAddedMails(IReadOnlyList<MailCopy> addedMails)
{
if (addedMails == null || addedMails.Count == 0)
return;
if (addedMails.Count == 1)
ReportUIChange(new MailAddedMessage(addedMails[0], EntityUpdateSource.Server));
else
ReportUIChange(new BulkMailAddedMessage(addedMails, EntityUpdateSource.Server));
}
private void ReportUpdatedMails(IReadOnlyList<MailCopy> updatedMails, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None)
{
if (updatedMails == null || updatedMails.Count == 0)
return;
if (updatedMails.Count == 1)
ReportUIChange(new MailUpdatedMessage(updatedMails[0], EntityUpdateSource.Server, changedProperties));
else
ReportUIChange(new BulkMailUpdatedMessage(updatedMails, EntityUpdateSource.Server, changedProperties));
}
private void ReportRemovedMails(IReadOnlyList<MailCopy> removedMails)
{
if (removedMails == null || removedMails.Count == 0)
return;
if (removedMails.Count == 1)
ReportUIChange(new MailRemovedMessage(removedMails[0], EntityUpdateSource.Server));
else
ReportUIChange(new BulkMailRemovedMessage(removedMails, EntityUpdateSource.Server));
} }
#region Repository Calls #region Repository Calls
private async Task InsertMailAsync(MailCopy mailCopy) private async Task<MailCopy> InsertMailAsync(MailCopy mailCopy, bool reportUiChange)
{ {
if (mailCopy == null) if (mailCopy == null)
{ {
_logger.Warning("Null mail passed to InsertMailAsync call."); _logger.Warning("Null mail passed to InsertMailAsync call.");
return; return null;
} }
if (mailCopy.FolderId == Guid.Empty) if (mailCopy.FolderId == Guid.Empty)
{ {
_logger.Warning("Invalid FolderId for MailCopyId {Id} for InsertMailAsync", mailCopy.Id); _logger.Warning("Invalid FolderId for MailCopyId {Id} for InsertMailAsync", mailCopy.Id);
return; return null;
} }
_logger.Debug("Inserting mail {MailCopyId} to {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName); _logger.Debug("Inserting mail {MailCopyId} to {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
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);
if (reportUiChange)
ReportAddedMails([hydratedMailCopy]);
return hydratedMailCopy;
} }
public async Task UpdateMailAsync(MailCopy mailCopy) public async Task UpdateMailAsync(MailCopy mailCopy)
=> await UpdateMailAsync(mailCopy, reportUiChange: true).ConfigureAwait(false);
private async Task<MailCopy> UpdateMailAsync(MailCopy mailCopy, bool reportUiChange, MailCopy existingMailCopy = null)
{ {
if (mailCopy == null) if (mailCopy == null)
{ {
_logger.Warning("Null mail passed to UpdateMailAsync call."); _logger.Warning("Null mail passed to UpdateMailAsync call.");
return; return null;
} }
_logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId); _logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
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);
if (reportUiChange)
ReportUpdatedMails([hydratedMailCopy]);
return hydratedMailCopy;
} }
private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile) private async Task<MailCopy> DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile, bool reportUiChange)
{ {
if (mailCopy == null) if (mailCopy == null)
{ {
_logger.Warning("Null mail passed to DeleteMailAsync call."); _logger.Warning("Null mail passed to DeleteMailAsync call.");
return; return null;
} }
_logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName); _logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
@@ -776,7 +859,31 @@ public class MailService : BaseDatabaseService, IMailService
await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false); await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false);
} }
ReportUIChange(new MailRemovedMessage(mailCopy, EntityUpdateSource.Server)); if (reportUiChange)
ReportRemovedMails([mailCopy]);
return mailCopy;
}
private async Task<List<MailCopy>> DeleteMailCopiesAsync(IReadOnlyList<MailCopy> mailCopies, bool preserveMimeFile, bool reportUiChange)
{
if (mailCopies == null || mailCopies.Count == 0)
return [];
var removedMails = new List<MailCopy>(mailCopies.Count);
foreach (var mailCopy in mailCopies)
{
var removedMail = await DeleteMailInternalAsync(mailCopy, preserveMimeFile, reportUiChange: false).ConfigureAwait(false);
if (removedMail != null)
removedMails.Add(removedMail);
}
if (reportUiChange)
ReportRemovedMails(removedMails);
return removedMails;
} }
#endregion #endregion
@@ -807,12 +914,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 +993,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);
@@ -939,6 +1092,40 @@ public class MailService : BaseDatabaseService, IMailService
} }
public async Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId) public async Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
=> await CreateAssignmentsAsync(accountId, [new MailFolderAssignmentUpdate(mailCopyId, remoteFolderId)]).ConfigureAwait(false);
public async Task CreateAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments)
{
var targetAssignments = assignments?
.Where(x => x != null &&
!string.IsNullOrWhiteSpace(x.MailCopyId) &&
!string.IsNullOrWhiteSpace(x.RemoteFolderId))
.GroupBy(x => $"{x.MailCopyId}\u001f{x.RemoteFolderId}", StringComparer.Ordinal)
.Select(group => group.First())
.ToList() ?? [];
if (targetAssignments.Count == 0)
return;
var addedMails = new List<MailCopy>(targetAssignments.Count);
var removedMails = new List<MailCopy>();
foreach (var assignment in targetAssignments)
{
var (addedMail, removedMail) = await CreateAssignmentInternalAsync(accountId, assignment.MailCopyId, assignment.RemoteFolderId).ConfigureAwait(false);
if (removedMail != null)
removedMails.Add(removedMail);
if (addedMail != null)
addedMails.Add(addedMail);
}
ReportRemovedMails(removedMails);
ReportAddedMails(addedMails);
}
private async Task<(MailCopy AddedMail, MailCopy RemovedMail)> CreateAssignmentInternalAsync(Guid accountId, string mailCopyId, string remoteFolderId)
{ {
// Note: Folder might not be available at the moment due to user not syncing folders before the delta processing. // Note: Folder might not be available at the moment due to user not syncing folders before the delta processing.
// This is a problem, because assignments won't be created. // This is a problem, because assignments won't be created.
@@ -951,14 +1138,14 @@ public class MailService : BaseDatabaseService, IMailService
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId); _logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
_logger.Warning("Skipping assignment creation for the the message {MailCopyId}", mailCopyId); _logger.Warning("Skipping assignment creation for the the message {MailCopyId}", mailCopyId);
return; return (null, null);
} }
if (await IsMailExistsAsync(mailCopyId, localFolder.Id).ConfigureAwait(false)) if (await IsMailExistsAsync(mailCopyId, localFolder.Id).ConfigureAwait(false))
{ {
_logger.Debug("Skipping assignment creation for {MailCopyId} because folder {FolderId} already has a local copy.", _logger.Debug("Skipping assignment creation for {MailCopyId} because folder {FolderId} already has a local copy.",
mailCopyId, localFolder.Id); mailCopyId, localFolder.Id);
return; return (null, null);
} }
var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId); var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId);
@@ -967,9 +1154,12 @@ public class MailService : BaseDatabaseService, IMailService
{ {
_logger.Warning("Can't create assignment for mail {MailCopyId} because it does not exist.", mailCopyId); _logger.Warning("Can't create assignment for mail {MailCopyId} because it does not exist.", mailCopyId);
return; return (null, null);
} }
MailCopy removedMail = null;
var mailCopyToInsert = mailCopy;
if (mailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent && if (mailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent &&
localFolder.SpecialFolderType == SpecialFolderType.Deleted) localFolder.SpecialFolderType == SpecialFolderType.Deleted)
{ {
@@ -980,21 +1170,52 @@ public class MailService : BaseDatabaseService, IMailService
// This way item will only be visible in Trash folder as in Gmail Web UI. // This way item will only be visible in Trash folder as in Gmail Web UI.
// Don't delete MIME file since if exists. // Don't delete MIME file since if exists.
await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true).ConfigureAwait(false); mailCopyToInsert = CloneMailCopy(mailCopy);
removedMail = await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true, reportUiChange: false).ConfigureAwait(false);
} }
// Copy one of the mail copy and assign it to the new folder. // Copy one of the mail copy and assign it to the new folder.
// We don't need to create a new MIME pack. // We don't need to create a new MIME pack.
// Therefore FileId is not changed for the new MailCopy. // Therefore FileId is not changed for the new MailCopy.
mailCopy.UniqueId = Guid.NewGuid(); mailCopyToInsert.UniqueId = Guid.NewGuid();
mailCopy.FolderId = localFolder.Id; mailCopyToInsert.FolderId = localFolder.Id;
mailCopy.AssignedFolder = localFolder; mailCopyToInsert.AssignedFolder = localFolder;
await InsertMailAsync(mailCopy).ConfigureAwait(false); var addedMail = await InsertMailAsync(mailCopyToInsert, reportUiChange: false).ConfigureAwait(false);
return (addedMail, removedMail);
} }
public async Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId) public async Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
=> await DeleteAssignmentsAsync(accountId, [new MailFolderAssignmentUpdate(mailCopyId, remoteFolderId)]).ConfigureAwait(false);
public async Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments)
{
var targetAssignments = assignments?
.Where(x => x != null &&
!string.IsNullOrWhiteSpace(x.MailCopyId) &&
!string.IsNullOrWhiteSpace(x.RemoteFolderId))
.GroupBy(x => $"{x.MailCopyId}\u001f{x.RemoteFolderId}", StringComparer.Ordinal)
.Select(group => group.First())
.ToList() ?? [];
if (targetAssignments.Count == 0)
return;
var removedMails = new List<MailCopy>(targetAssignments.Count);
foreach (var assignment in targetAssignments)
{
var removedMail = await DeleteAssignmentInternalAsync(accountId, assignment.MailCopyId, assignment.RemoteFolderId).ConfigureAwait(false);
if (removedMail != null)
removedMails.Add(removedMail);
}
ReportRemovedMails(removedMails);
}
private async Task<MailCopy> DeleteAssignmentInternalAsync(Guid accountId, string mailCopyId, string remoteFolderId)
{ {
var mailItem = await GetSingleMailItemAsync(mailCopyId, remoteFolderId).ConfigureAwait(false); var mailItem = await GetSingleMailItemAsync(mailCopyId, remoteFolderId).ConfigureAwait(false);
@@ -1002,7 +1223,7 @@ public class MailService : BaseDatabaseService, IMailService
{ {
_logger.Warning("Mail not found with id {MailCopyId} with remote folder {RemoteFolderId}", mailCopyId, remoteFolderId); _logger.Warning("Mail not found with id {MailCopyId} with remote folder {RemoteFolderId}", mailCopyId, remoteFolderId);
return; return null;
} }
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId); var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
@@ -1011,10 +1232,50 @@ public class MailService : BaseDatabaseService, IMailService
{ {
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId); _logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
return; return null;
} }
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false); return await DeleteMailInternalAsync(mailItem, preserveMimeFile: false, reportUiChange: false).ConfigureAwait(false);
}
private static MailCopy CloneMailCopy(MailCopy source)
{
if (source == null)
return null;
return new MailCopy
{
UniqueId = source.UniqueId,
Id = source.Id,
FolderId = source.FolderId,
ThreadId = source.ThreadId,
MessageId = source.MessageId,
References = source.References,
InReplyTo = source.InReplyTo,
FromName = source.FromName,
FromAddress = source.FromAddress,
Subject = source.Subject,
PreviewText = source.PreviewText,
CreationDate = source.CreationDate,
Importance = source.Importance,
IsRead = source.IsRead,
IsFlagged = source.IsFlagged,
IsPinned = source.IsPinned,
IsFocused = source.IsFocused,
HasAttachments = source.HasAttachments,
ItemType = source.ItemType,
DraftId = source.DraftId,
IsDraft = source.IsDraft,
FileId = source.FileId,
AssignedFolder = source.AssignedFolder,
AssignedAccount = source.AssignedAccount,
SenderContact = source.SenderContact,
IsReadReceiptRequested = source.IsReadReceiptRequested,
ReadReceiptStatus = source.ReadReceiptStatus,
ReadReceiptAcknowledgedAtUtc = source.ReadReceiptAcknowledgedAtUtc,
ReadReceiptMessageUniqueId = source.ReadReceiptMessageUniqueId,
Categories = source.Categories == null ? [] : [.. source.Categories]
};
} }
public async Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package) public async Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package)
@@ -1031,7 +1292,7 @@ public class MailService : BaseDatabaseService, IMailService
await SaveContactsForPackageAsync(package).ConfigureAwait(false); await SaveContactsForPackageAsync(package).ConfigureAwait(false);
var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id); var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id);
var insertMailTask = InsertMailAsync(mailCopy); var insertMailTask = InsertMailAsync(mailCopy, reportUiChange: true);
await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false); await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
@@ -1043,6 +1304,129 @@ public class MailService : BaseDatabaseService, IMailService
} }
public async Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages)
{
var targetPackages = packages?
.Where(package => package != null)
.ToList() ?? [];
if (targetPackages.Count == 0)
return;
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
if (account == null)
return;
if (account.ProviderType != MailProviderType.Gmail)
{
foreach (var package in targetPackages)
await CreateMailAsync(accountId, package).ConfigureAwait(false);
return;
}
var pendingInserts = new List<(MailCopy MailCopy, NewMailItemPackage Package, MimeMessage MimeMessage)>();
var pendingUpdates = new List<(MailCopy MailCopy, MailCopy ExistingMailCopy, NewMailItemPackage Package, MimeMessage MimeMessage)>();
foreach (var package in targetPackages)
{
if (string.IsNullOrEmpty(package.AssignedRemoteFolderId))
{
_logger.Warning("Remote folder id is not set for {MailCopyId}.", package.Copy?.Id);
_logger.Warning("Ignoring creation of mail.");
continue;
}
var assignedFolder = await _folderService.GetFolderAsync(accountId, package.AssignedRemoteFolderId).ConfigureAwait(false);
if (assignedFolder == null)
{
_logger.Warning("Assigned folder not found for {MailCopyId}.", package.Copy?.Id);
_logger.Warning("Ignoring creation of mail.");
continue;
}
var mailCopy = package.Copy;
var mimeMessage = package.Mime;
mailCopy.UniqueId = Guid.NewGuid();
mailCopy.AssignedAccount = account;
mailCopy.AssignedFolder = assignedFolder;
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
mailCopy.FolderId = assignedFolder.Id;
if (mimeMessage != null)
{
var isMimeExists = await _mimeFileService.IsMimeExistAsync(accountId, mailCopy.FileId).ConfigureAwait(false);
if (!isMimeExists)
{
bool isMimeSaved = await _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, accountId).ConfigureAwait(false);
if (!isMimeSaved)
{
_logger.Warning("Failed to save mime file for {MailCopyId}.", mailCopy.Id);
}
}
}
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
var existingCopyItem = await Connection.Table<MailCopy>()
.FirstOrDefaultAsync(a => a.Id == mailCopy.Id && a.FolderId == assignedFolder.Id)
.ConfigureAwait(false);
if (existingCopyItem != null)
{
mailCopy.UniqueId = existingCopyItem.UniqueId;
pendingUpdates.Add((mailCopy, existingCopyItem, package, mimeMessage));
}
else
{
pendingInserts.Add((mailCopy, package, mimeMessage));
}
}
var insertedMails = new List<MailCopy>(pendingInserts.Count);
foreach (var pendingInsert in pendingInserts)
{
var insertedMail = await InsertMailAsync(pendingInsert.MailCopy, reportUiChange: false).ConfigureAwait(false);
if (insertedMail != null)
insertedMails.Add(insertedMail);
}
var updatedMails = new List<MailCopy>(pendingUpdates.Count);
foreach (var pendingUpdate in pendingUpdates)
{
var updatedMail = await UpdateMailAsync(
pendingUpdate.MailCopy,
reportUiChange: false,
existingMailCopy: pendingUpdate.ExistingMailCopy).ConfigureAwait(false);
if (updatedMail != null)
updatedMails.Add(updatedMail);
}
ReportAddedMails(insertedMails);
ReportUpdatedMails(updatedMails);
foreach (var pendingInsert in pendingInserts)
{
await ReplaceMailCategoriesForPackageAsync(accountId, pendingInsert.MailCopy, pendingInsert.Package).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(pendingInsert.MailCopy, pendingInsert.MimeMessage).ConfigureAwait(false);
await _sentMailReceiptService.ProcessIncomingReceiptAsync(pendingInsert.MailCopy, pendingInsert.MimeMessage).ConfigureAwait(false);
}
foreach (var pendingUpdate in pendingUpdates)
{
await ReplaceMailCategoriesForPackageAsync(accountId, pendingUpdate.MailCopy, pendingUpdate.Package).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(pendingUpdate.MailCopy, pendingUpdate.MimeMessage).ConfigureAwait(false);
await _sentMailReceiptService.ProcessIncomingReceiptAsync(pendingUpdate.MailCopy, pendingUpdate.MimeMessage).ConfigureAwait(false);
}
}
public async Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package) public async Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
{ {
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
@@ -1111,7 +1495,7 @@ public class MailService : BaseDatabaseService, IMailService
{ {
mailCopy.UniqueId = existingCopyItem.UniqueId; mailCopy.UniqueId = existingCopyItem.UniqueId;
await UpdateMailAsync(mailCopy).ConfigureAwait(false); await UpdateMailAsync(mailCopy, reportUiChange: true, existingMailCopy: existingCopyItem).ConfigureAwait(false);
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false); await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
@@ -1128,7 +1512,7 @@ public class MailService : BaseDatabaseService, IMailService
await DeleteMailAsync(accountId, mailCopy.Id).ConfigureAwait(false); await DeleteMailAsync(accountId, mailCopy.Id).ConfigureAwait(false);
} }
await InsertMailAsync(mailCopy).ConfigureAwait(false); await InsertMailAsync(mailCopy, reportUiChange: true).ConfigureAwait(false);
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false); await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Wino.Core.Tests")]
@@ -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;
}
} }
+68 -3
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
@@ -19,17 +20,19 @@ public class TranslationService : ITranslationService
private ILogger _logger = Log.ForContext<TranslationService>(); private ILogger _logger = Log.ForContext<TranslationService>();
private readonly IPreferencesService _preferencesService; private readonly IPreferencesService _preferencesService;
private readonly IConfigurationService _configurationService;
private bool isInitialized = false; private bool isInitialized = false;
public AppLanguageModel CurrentLanguageModel { get; private set; } public AppLanguageModel CurrentLanguageModel { get; private set; }
public TranslationService(IPreferencesService preferencesService) public TranslationService(IPreferencesService preferencesService, IConfigurationService configurationService)
{ {
_preferencesService = preferencesService; _preferencesService = preferencesService;
_configurationService = configurationService;
} }
// Initialize default language with ignoring current language check. // Initialize default language with ignoring current language check.
public Task InitializeAsync() => InitializeLanguageAsync(_preferencesService.CurrentLanguage, ignoreCurrentLanguageCheck: true); public Task InitializeAsync() => InitializeLanguageAsync(GetInitialLanguage(), ignoreCurrentLanguageCheck: true);
public async Task InitializeLanguageAsync(AppLanguage language, bool ignoreCurrentLanguageCheck = false) public async Task InitializeLanguageAsync(AppLanguage language, bool ignoreCurrentLanguageCheck = false)
{ {
@@ -65,6 +68,67 @@ public class TranslationService : ITranslationService
WeakReferenceMessenger.Default.Send(new LanguageChanged()); WeakReferenceMessenger.Default.Send(new LanguageChanged());
} }
private AppLanguage GetInitialLanguage()
{
if (_configurationService.Contains(nameof(IPreferencesService.CurrentLanguage)))
return _preferencesService.CurrentLanguage;
var windowsDisplayLanguage = CultureInfo.CurrentUICulture?.Name;
var initialLanguage = ResolveSupportedLanguage(
[
windowsDisplayLanguage ?? string.Empty,
CultureInfo.CurrentUICulture?.TwoLetterISOLanguageName ?? string.Empty
]);
_logger.Information("No saved app language preference found. Using Windows display language {LanguageTag} -> {Language}.",
string.IsNullOrWhiteSpace(windowsDisplayLanguage) ? "<unknown>" : windowsDisplayLanguage,
initialLanguage);
return initialLanguage;
}
internal static AppLanguage ResolveSupportedLanguage(IEnumerable<string> languageTags)
{
foreach (var languageTag in languageTags)
{
if (string.IsNullOrWhiteSpace(languageTag))
continue;
var normalizedLanguageTag = languageTag.Replace('_', '-').Trim();
var languageCode = normalizedLanguageTag.Split('-')[0];
if (TryResolveSupportedLanguage(languageCode, out var supportedLanguage))
return supportedLanguage;
}
return DefaultAppLanguage;
}
private static bool TryResolveSupportedLanguage(string languageCode, out AppLanguage supportedLanguage)
{
supportedLanguage = languageCode.ToLowerInvariant() switch
{
"cs" => AppLanguage.Czech,
"de" => AppLanguage.Deutsch,
"el" => AppLanguage.Greek,
"en" => AppLanguage.English,
"es" => AppLanguage.Spanish,
"fr" => AppLanguage.French,
"id" => AppLanguage.Indonesian,
"it" => AppLanguage.Italian,
"ko" => AppLanguage.Korean,
"pl" => AppLanguage.Polish,
"pt" => AppLanguage.PortugeseBrazil,
"ro" => AppLanguage.Romanian,
"ru" => AppLanguage.Russian,
"tr" => AppLanguage.Turkish,
"zh" => AppLanguage.Chinese,
_ => AppLanguage.None
};
return supportedLanguage != AppLanguage.None;
}
public List<AppLanguageModel> GetAvailableLanguages() public List<AppLanguageModel> GetAvailableLanguages()
{ {
return return
@@ -79,6 +143,7 @@ public class TranslationService : ITranslationService
new AppLanguageModel(AppLanguage.Indonesian, "Indonesian", "id-ID"), new AppLanguageModel(AppLanguage.Indonesian, "Indonesian", "id-ID"),
new AppLanguageModel(AppLanguage.Polish, "Polski", "pl-PL"), new AppLanguageModel(AppLanguage.Polish, "Polski", "pl-PL"),
new AppLanguageModel(AppLanguage.PortugeseBrazil, "Portuguese-Brazil", "pt-BR"), new AppLanguageModel(AppLanguage.PortugeseBrazil, "Portuguese-Brazil", "pt-BR"),
new AppLanguageModel(AppLanguage.Korean, "Korean", "ko-KR"),
new AppLanguageModel(AppLanguage.Russian, "Russian", "ru-RU"), new AppLanguageModel(AppLanguage.Russian, "Russian", "ru-RU"),
new AppLanguageModel(AppLanguage.Romanian, "Romanian", "ro-RO"), new AppLanguageModel(AppLanguage.Romanian, "Romanian", "ro-RO"),
new AppLanguageModel(AppLanguage.Spanish, "Spanish", "es-ES"), new AppLanguageModel(AppLanguage.Spanish, "Spanish", "es-ES"),
+1
View File
@@ -47,6 +47,7 @@ LOCALE_LABELS = {
"id_ID": "Indonesian (Indonesia)", "id_ID": "Indonesian (Indonesia)",
"it_IT": "Italian (Italy)", "it_IT": "Italian (Italy)",
"ja_JP": "Japanese (Japan)", "ja_JP": "Japanese (Japan)",
"ko_KR": "Korean (South Korea)",
"lt_LT": "Lithuanian (Lithuania)", "lt_LT": "Lithuanian (Lithuania)",
"nl_NL": "Dutch (Netherlands)", "nl_NL": "Dutch (Netherlands)",
"pl_PL": "Polish (Poland)", "pl_PL": "Polish (Poland)",