From 42e51571a8fe83dac10752bc0189e472cbb26bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sun, 15 Feb 2026 11:27:30 +0100 Subject: [PATCH] Bunch of calendar implementation thing. --- .../Accounts/SpecialImapProviderDetails.cs | 7 +- .../Extensions/GoogleIntegratorExtensions.cs | 6 +- .../Extensions/OutlookIntegratorExtensions.cs | 6 +- Wino.Core/Misc/ColorHelpers.cs | 120 ++++-- .../Requests/Bundles/TaskRequestBundle.cs | 8 +- Wino.Core/Services/SynchronizerFactory.cs | 7 +- Wino.Core/Synchronizers/GmailSynchronizer.cs | 12 +- Wino.Core/Synchronizers/ImapSynchronizer.cs | 357 +++++++++++++++++- .../Synchronizers/OutlookSynchronizer.cs | 12 +- .../AccountManagementViewModel.cs | 195 ++++++++-- Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml | 9 +- .../Dialogs/NewAccountDialog.xaml.cs | 27 +- .../Views/Account/ImapCalDavSettingsPage.xaml | 75 +++- .../SpecialImapProviderConfigResolver.cs | 91 +++-- 14 files changed, 804 insertions(+), 128 deletions(-) diff --git a/Wino.Core.Domain/Models/Accounts/SpecialImapProviderDetails.cs b/Wino.Core.Domain/Models/Accounts/SpecialImapProviderDetails.cs index 1286d431..a91e9f35 100644 --- a/Wino.Core.Domain/Models/Accounts/SpecialImapProviderDetails.cs +++ b/Wino.Core.Domain/Models/Accounts/SpecialImapProviderDetails.cs @@ -2,4 +2,9 @@ namespace Wino.Core.Domain.Models.Accounts; -public record SpecialImapProviderDetails(string Address, string Password, string SenderName, SpecialImapProvider SpecialImapProvider); +public record SpecialImapProviderDetails( + string Address, + string Password, + string SenderName, + SpecialImapProvider SpecialImapProvider, + ImapCalendarSupportMode CalendarSupportMode = ImapCalendarSupportMode.CalDav); diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index 429409e5..51b6f810 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -131,7 +131,7 @@ public static class GoogleIntegratorExtensions }).ToList(); } - public static AccountCalendar AsCalendar(this CalendarListEntry calendarListEntry, Guid accountId) + public static AccountCalendar AsCalendar(this CalendarListEntry calendarListEntry, Guid accountId, string fallbackBackgroundColor = null) { var calendar = new AccountCalendar() { @@ -147,7 +147,9 @@ public static class GoogleIntegratorExtensions // Bg color must present. Generate one if doesnt exists. // Text color is optional. It'll be overriden by UI for readibility. - calendar.BackgroundColorHex = string.IsNullOrEmpty(calendarListEntry.BackgroundColor) ? ColorHelpers.GenerateFlatColorHex() : calendarListEntry.BackgroundColor; + calendar.BackgroundColorHex = string.IsNullOrEmpty(calendarListEntry.BackgroundColor) + ? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex() + : calendarListEntry.BackgroundColor; calendar.TextColorHex = string.IsNullOrEmpty(calendarListEntry.ForegroundColor) ? "#000000" : calendarListEntry.ForegroundColor; return calendar; diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 4a95160b..140bdd79 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -174,7 +174,7 @@ public static class OutlookIntegratorExtensions return message; } - public static AccountCalendar AsCalendar(this Calendar outlookCalendar, MailAccount assignedAccount) + public static AccountCalendar AsCalendar(this Calendar outlookCalendar, MailAccount assignedAccount, string fallbackBackgroundColor = null) { var calendar = new AccountCalendar() { @@ -191,7 +191,9 @@ public static class OutlookIntegratorExtensions // Bg must be present. Generate flat one if doesn't exists. // Text doesnt exists for Outlook. - calendar.BackgroundColorHex = string.IsNullOrEmpty(outlookCalendar.HexColor) ? ColorHelpers.GenerateFlatColorHex() : outlookCalendar.HexColor; + calendar.BackgroundColorHex = string.IsNullOrEmpty(outlookCalendar.HexColor) + ? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex() + : outlookCalendar.HexColor; calendar.TextColorHex = "#000000"; return calendar; diff --git a/Wino.Core/Misc/ColorHelpers.cs b/Wino.Core/Misc/ColorHelpers.cs index 5fdf9f83..752a0bd6 100644 --- a/Wino.Core/Misc/ColorHelpers.cs +++ b/Wino.Core/Misc/ColorHelpers.cs @@ -1,49 +1,115 @@ -using System; +using System; +using System.Collections.Generic; using System.Drawing; +using System.Globalization; namespace Wino.Core.Misc; public static class ColorHelpers { - public static string GenerateFlatColorHex() + private static readonly string[] FlatUiColorPalette = + [ + "#B91C1C", "#C2410C", "#B45309", "#A16207", "#4D7C0F", "#15803D", "#047857", "#0F766E", "#0E7490", "#0369A1", + "#1D4ED8", "#4338CA", "#6D28D9", "#7E22CE", "#A21CAF", "#BE185D", "#E11D48", "#DC2626", "#EA580C", "#D97706", + "#CA8A04", "#65A30D", "#16A34A", "#059669", "#0D9488", "#0891B2", "#0284C7", "#2563EB", "#4F46E5", "#7C3AED", + "#9333EA", "#C026D3", "#DB2777", "#F43F5E", "#EF4444", "#F97316", "#F59E0B", "#EAB308", "#84CC16", "#22C55E", + "#10B981", "#14B8A6", "#06B6D4", "#0EA5E9", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7", "#D946EF", "#EC4899", + "#FB7185", "#F87171", "#FB923C", "#FBBF24", "#FACC15", "#A3E635", "#4ADE80", "#34D399", "#2DD4BF", "#22D3EE", + "#38BDF8", "#60A5FA", "#818CF8", "#A78BFA", "#C084FC", "#E879F9", "#F472B6", "#FDA4AF", "#FCA5A5", "#FDBA74", + "#FCD34D", "#FDE047", "#BEF264", "#86EFAC", "#6EE7B7", "#5EEAD4", "#67E8F9", "#7DD3FC", "#93C5FD", "#A5B4FC", + "#C4B5FD", "#D8B4FE", "#F0ABFC", "#F9A8D4", "#A16207", "#9A3412", "#7C2D12", "#6F1D1B", "#7F1D1D", "#881337", + "#831843", "#701A75", "#581C87", "#312E81", "#1E3A8A", "#1D4ED8", "#155E75", "#134E4A", "#14532D", "#3F6212", + "#365314", "#3F3F46", "#52525B", "#57534E", "#44403C", "#78716C", "#6B7280", "#4B5563", "#374151", "#1F2937", + "#A16207", "#B45309", "#C2410C", "#9F1239", "#BE123C", "#C026D3", "#7E22CE", "#6D28D9", "#4338CA", "#1D4ED8" + ]; + + public static IReadOnlyList GetFlatColorPalette() => FlatUiColorPalette; + + public static string GenerateFlatColorHex() => GetDistinctFlatColorHex(Array.Empty()); + + public static string GetDistinctFlatColorHex(IEnumerable usedColors) { - Random random = new(); - int hue = random.Next(0, 360); // Full hue range - int saturation = 70 + random.Next(30); // High saturation (70-100%) - int lightness = 50 + random.Next(20); // Bright colors (50-70%) + var used = new HashSet(StringComparer.OrdinalIgnoreCase); - var color = FromHsl(hue, saturation, lightness); + if (usedColors != null) + { + foreach (var color in usedColors) + { + if (TryNormalizeHexColor(color, out var normalized)) + { + used.Add(normalized); + } + } + } - return ToHexString(color); + foreach (var color in FlatUiColorPalette) + { + if (!used.Contains(color)) + { + return color; + } + } + + var attempt = 0; + while (attempt < 500) + { + var baseColor = FlatUiColorPalette[attempt % FlatUiColorPalette.Length]; + var cycle = (attempt / FlatUiColorPalette.Length) + 1; + var candidate = AdjustColor(baseColor, cycle); + + if (!used.Contains(candidate)) + { + return candidate; + } + + attempt++; + } + + return "#5C7A8A"; } public static string ToHexString(this Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}"; public static string ToRgbString(this Color c) => $"RGB({c.R}, {c.G}, {c.B})"; - private static Color FromHsl(int h, int s, int l) + private static bool TryNormalizeHexColor(string value, out string normalized) { - double hue = h / 360.0; - double saturation = s / 100.0; - double lightness = l / 100.0; + normalized = null; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } - // Conversion from HSL to RGB - var chroma = (1 - Math.Abs(2 * lightness - 1)) * saturation; - var x = chroma * (1 - Math.Abs((hue * 6) % 2 - 1)); - var m = lightness - chroma / 2; + var color = value.Trim(); + if (color.StartsWith('#')) + { + color = color[1..]; + } - double r = 0, g = 0, b = 0; + if (color.Length != 6) + { + return false; + } - if (hue < 1.0 / 6.0) { r = chroma; g = x; b = 0; } - else if (hue < 2.0 / 6.0) { r = x; g = chroma; b = 0; } - else if (hue < 3.0 / 6.0) { r = 0; g = chroma; b = x; } - else if (hue < 4.0 / 6.0) { r = 0; g = x; b = chroma; } - else if (hue < 5.0 / 6.0) { r = x; g = 0; b = chroma; } - else { r = chroma; g = 0; b = x; } + if (!int.TryParse(color, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _)) + { + return false; + } - return Color.FromArgb( - (int)((r + m) * 255), - (int)((g + m) * 255), - (int)((b + m) * 255)); + normalized = $"#{color.ToUpperInvariant()}"; + return true; + } + + private static string AdjustColor(string hexColor, int cycle) + { + var color = ColorTranslator.FromHtml(hexColor); + var factor = Math.Max(0.55, 1.0 - (cycle * 0.08)); + + var adjusted = Color.FromArgb( + (int)Math.Clamp(color.R * factor, 0, 255), + (int)Math.Clamp(color.G * factor, 0, 255), + (int)Math.Clamp(color.B * factor, 0, 255)); + + return adjusted.ToHexString(); } } diff --git a/Wino.Core/Requests/Bundles/TaskRequestBundle.cs b/Wino.Core/Requests/Bundles/TaskRequestBundle.cs index 7e968cfb..2db25b41 100644 --- a/Wino.Core/Requests/Bundles/TaskRequestBundle.cs +++ b/Wino.Core/Requests/Bundles/TaskRequestBundle.cs @@ -9,18 +9,20 @@ public class ImapRequest { public Func IntegratorTask { get; } public IRequestBase Request { get; } + public bool RequiresConnectedClient { get; } - public ImapRequest(Func integratorTask, IRequestBase request) + public ImapRequest(Func integratorTask, IRequestBase request, bool requiresConnectedClient = true) { IntegratorTask = integratorTask; Request = request; + RequiresConnectedClient = requiresConnectedClient; } } public class ImapRequest : ImapRequest where TRequestBaseType : IRequestBase { - public ImapRequest(Func integratorTask, TRequestBaseType request) - : base((client, request) => integratorTask(client, (TRequestBaseType)request), request) + public ImapRequest(Func integratorTask, TRequestBaseType request, bool requiresConnectedClient = true) + : base((client, request) => integratorTask(client, (TRequestBaseType)request), request, requiresConnectedClient) { } } diff --git a/Wino.Core/Services/SynchronizerFactory.cs b/Wino.Core/Services/SynchronizerFactory.cs index 501dc43c..7aa25880 100644 --- a/Wino.Core/Services/SynchronizerFactory.cs +++ b/Wino.Core/Services/SynchronizerFactory.cs @@ -25,6 +25,7 @@ public class SynchronizerFactory : ISynchronizerFactory private readonly UnifiedImapSynchronizer _unifiedImapSynchronizer; private readonly ICalDavClient _calDavClient; private readonly IAutoDiscoveryService _autoDiscoveryService; + private readonly ICalendarService _calendarService; private readonly List synchronizerCache = new(); @@ -39,7 +40,8 @@ public class SynchronizerFactory : ISynchronizerFactory IImapSynchronizerErrorHandlerFactory imapSynchronizerErrorHandlerFactory, UnifiedImapSynchronizer unifiedImapSynchronizer, ICalDavClient calDavClient, - IAutoDiscoveryService autoDiscoveryService) + IAutoDiscoveryService autoDiscoveryService, + ICalendarService calendarService) { _outlookChangeProcessor = outlookChangeProcessor; _gmailChangeProcessor = gmailChangeProcessor; @@ -53,6 +55,7 @@ public class SynchronizerFactory : ISynchronizerFactory _unifiedImapSynchronizer = unifiedImapSynchronizer; _calDavClient = calDavClient; _autoDiscoveryService = autoDiscoveryService; + _calendarService = calendarService; } public async Task GetAccountSynchronizerAsync(Guid accountId) @@ -88,7 +91,7 @@ public class SynchronizerFactory : ISynchronizerFactory var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator; return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory); case Domain.Enums.MailProviderType.IMAP4: - return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory, _calDavClient, _autoDiscoveryService); + return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory, _calDavClient, _autoDiscoveryService, _calendarService); default: break; } diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 6970cbbb..08926fc2 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -34,6 +34,7 @@ using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Extensions; using Wino.Core.Http; using Wino.Core.Integration.Processors; +using Wino.Core.Misc; using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Calendar; using Wino.Core.Requests.Folder; @@ -583,6 +584,11 @@ public class GmailSynchronizer : WinoSynchronizer( + localCalendars + .Select(a => a.BackgroundColorHex) + .Where(a => !string.IsNullOrWhiteSpace(a)), + StringComparer.OrdinalIgnoreCase); List insertedCalendars = new(); List updatedCalendars = new(); @@ -612,8 +618,12 @@ public class GmailSynchronizer : WinoSynchronizer> CreateCalendarEvent(CreateCalendarEventRequest request) + { + var handler = ResolveCalendarOperationHandler(); + return CreateCalendarOperationTaskBundle( + request, + async value => await handler.CreateCalendarEventAsync(value).ConfigureAwait(false), + handler.RequiresConnectedClient); + } + + public override List> UpdateCalendarEvent(UpdateCalendarEventRequest request) + { + var handler = ResolveCalendarOperationHandler(); + return CreateCalendarOperationTaskBundle( + request, + async value => await handler.UpdateCalendarEventAsync(value).ConfigureAwait(false), + handler.RequiresConnectedClient); + } + + public override List> DeleteCalendarEvent(DeleteCalendarEventRequest request) + { + var handler = ResolveCalendarOperationHandler(); + return CreateCalendarOperationTaskBundle( + request, + async value => await handler.DeleteCalendarEventAsync(value).ConfigureAwait(false), + handler.RequiresConnectedClient); + } + + public override List> AcceptEvent(AcceptEventRequest request) + { + var handler = ResolveCalendarOperationHandler(); + return CreateCalendarOperationTaskBundle( + request, + async value => await handler.AcceptEventAsync(value).ConfigureAwait(false), + handler.RequiresConnectedClient); + } + + public override List> DeclineEvent(DeclineEventRequest request) + { + var handler = ResolveCalendarOperationHandler(); + return CreateCalendarOperationTaskBundle( + request, + async value => await handler.DeclineEventAsync(value).ConfigureAwait(false), + handler.RequiresConnectedClient); + } + + public override List> TentativeEvent(TentativeEventRequest request) + { + var handler = ResolveCalendarOperationHandler(); + return CreateCalendarOperationTaskBundle( + request, + async value => await handler.TentativeEventAsync(value).ConfigureAwait(false), + handler.RequiresConnectedClient); + } + + private IImapCalendarOperationHandler ResolveCalendarOperationHandler() + { + var mode = Account.ServerInformation?.CalendarSupportMode ?? ImapCalendarSupportMode.Disabled; + + return mode switch + { + ImapCalendarSupportMode.LocalOnly => _localCalendarOperationHandler, + ImapCalendarSupportMode.CalDav => _calDavCalendarOperationHandler, + _ => throw new NotSupportedException("Calendar operations are disabled for this IMAP account.") + }; + } + + private List> CreateCalendarOperationTaskBundle( + TRequest request, + Func operation, + bool requiresConnectedClient) + where TRequest : IRequestBase, IUIChangeRequest + { + return + [ + new ImapRequestBundle( + new ImapRequest((client, value) => operation(value), request, requiresConnectedClient), + request, + request) + ]; + } + #endregion public override async Task> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) @@ -635,7 +724,10 @@ public class ImapSynchronizer : WinoSynchronizer c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var usedCalendarColors = new HashSet( + localCalendars + .Select(a => a.BackgroundColorHex) + .Where(a => !string.IsNullOrWhiteSpace(a)), + StringComparer.OrdinalIgnoreCase); var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId; @@ -1274,11 +1374,12 @@ public class ImapSynchronizer : WinoSynchronizer false; + + public LocalCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService, string resourceScheme) + { + _account = account; + _changeProcessor = changeProcessor; + _calendarService = calendarService; + _resourceScheme = resourceScheme; + } + + public async Task CreateCalendarEventAsync(CreateCalendarEventRequest request) + { + var item = request.Item; + EnsureCalendarItemDefaults(item, _account, "local"); + item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false); + + var existing = await _calendarService.GetCalendarItemAsync(item.Id).ConfigureAwait(false); + + if (existing == null) + await _calendarService.CreateNewCalendarItemAsync(item, request.Attendees).ConfigureAwait(false); + else + await _calendarService.UpdateCalendarItemAsync(item, request.Attendees).ConfigureAwait(false); + + await PersistIcsAsync(item, request.Attendees).ConfigureAwait(false); + } + + public async Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request) + { + var item = request.Item; + EnsureCalendarItemDefaults(item, _account, "local"); + item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false); + + var attendees = request.Attendees ?? await _calendarService.GetAttendeesAsync(item.Id).ConfigureAwait(false); + + await _calendarService.UpdateCalendarItemAsync(item, attendees).ConfigureAwait(false); + await PersistIcsAsync(item, attendees).ConfigureAwait(false); + } + + public Task DeleteCalendarEventAsync(DeleteCalendarEventRequest request) + => _changeProcessor.DeleteCalendarItemAsync(request.Item.Id); + + public async Task AcceptEventAsync(AcceptEventRequest request) + { + request.Item.Status = CalendarItemStatus.Accepted; + await UpdateStatusAsync(request.Item).ConfigureAwait(false); + } + + public async Task DeclineEventAsync(DeclineEventRequest request) + { + request.Item.Status = CalendarItemStatus.Cancelled; + await UpdateStatusAsync(request.Item).ConfigureAwait(false); + } + + public async Task TentativeEventAsync(TentativeEventRequest request) + { + request.Item.Status = CalendarItemStatus.Tentative; + await UpdateStatusAsync(request.Item).ConfigureAwait(false); + } + + private async Task UpdateStatusAsync(CalendarItem item) + { + EnsureCalendarItemDefaults(item, _account, "local"); + item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false); + + var attendees = await _calendarService.GetAttendeesAsync(item.Id).ConfigureAwait(false); + await _calendarService.UpdateCalendarItemAsync(item, attendees).ConfigureAwait(false); + await PersistIcsAsync(item, attendees).ConfigureAwait(false); + } + + private Task PersistIcsAsync(CalendarItem item, List attendees) + { + var resourceHref = $"{_resourceScheme}://calendar/{item.CalendarId:N}/{item.Id:N}"; + var icsContent = BuildIcsContent(item, attendees); + + return _changeProcessor.SaveCalendarItemIcsAsync( + _account.Id, + item.CalendarId, + item.Id, + item.RemoteEventId, + resourceHref, + DateTimeOffset.UtcNow.ToString("O"), + icsContent); + } + } + + private sealed class CalDavCalendarOperationHandler : LocalCalendarOperationHandler + { + public CalDavCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService) + : base(account, changeProcessor, calendarService, "caldav") + { + } + } + + private static void EnsureCalendarItemDefaults(CalendarItem item, MailAccount account, string idPrefix) + { + if (item == null) + throw new ArgumentNullException(nameof(item)); + + if (item.Id == Guid.Empty) + item.Id = Guid.NewGuid(); + + if (string.IsNullOrWhiteSpace(item.RemoteEventId)) + item.RemoteEventId = $"{idPrefix}-{item.Id:N}"; + + if (item.CreatedAt == default) + item.CreatedAt = DateTimeOffset.UtcNow; + + item.UpdatedAt = DateTimeOffset.UtcNow; + item.OrganizerDisplayName ??= account?.SenderName ?? string.Empty; + item.OrganizerEmail ??= account?.Address ?? string.Empty; + item.StartTimeZone ??= TimeZoneInfo.Local.Id; + item.EndTimeZone ??= item.StartTimeZone; + } + + private static string BuildIcsContent(CalendarItem item, List attendees) + { + var uid = item.RemoteEventId?.Split(new[] { "::" }, StringSplitOptions.None)[0] ?? item.Id.ToString("N"); + var dtStamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'"); + + var lines = new List + { + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Wino Mail//Calendar//EN", + "CALSCALE:GREGORIAN", + "BEGIN:VEVENT", + $"UID:{EscapeIcs(uid)}", + $"DTSTAMP:{dtStamp}", + }; + + if (item.IsAllDayEvent) + { + lines.Add($"DTSTART;VALUE=DATE:{item.StartDate:yyyyMMdd}"); + lines.Add($"DTEND;VALUE=DATE:{item.EndDate:yyyyMMdd}"); + } + else + { + lines.Add($"DTSTART:{item.StartDate.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}"); + lines.Add($"DTEND:{item.EndDate.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}"); + } + + if (!string.IsNullOrWhiteSpace(item.Title)) + lines.Add($"SUMMARY:{EscapeIcs(item.Title)}"); + + if (!string.IsNullOrWhiteSpace(item.Description)) + lines.Add($"DESCRIPTION:{EscapeIcs(item.Description)}"); + + if (!string.IsNullOrWhiteSpace(item.Location)) + lines.Add($"LOCATION:{EscapeIcs(item.Location)}"); + + lines.Add($"STATUS:{MapStatus(item.Status)}"); + lines.Add($"TRANSP:{(item.ShowAs == CalendarItemShowAs.Free ? "TRANSPARENT" : "OPAQUE")}"); + lines.Add($"CLASS:{MapVisibility(item.Visibility)}"); + + if (!string.IsNullOrWhiteSpace(item.Recurrence)) + { + var recurrenceLines = item.Recurrence + .Split(Wino.Core.Domain.Constants.CalendarEventRecurrenceRuleSeperator, StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()) + .Where(l => !string.IsNullOrWhiteSpace(l)); + + lines.AddRange(recurrenceLines); + } + + if (!string.IsNullOrWhiteSpace(item.OrganizerEmail)) + { + var organizerName = string.IsNullOrWhiteSpace(item.OrganizerDisplayName) + ? item.OrganizerEmail + : item.OrganizerDisplayName; + lines.Add($"ORGANIZER;CN={EscapeIcs(organizerName)}:mailto:{EscapeIcs(item.OrganizerEmail)}"); + } + + if (attendees != null) + { + foreach (var attendee in attendees.Where(a => !string.IsNullOrWhiteSpace(a.Email))) + { + var role = attendee.IsOptionalAttendee ? "OPT-PARTICIPANT" : "REQ-PARTICIPANT"; + var partStat = attendee.AttendenceStatus switch + { + AttendeeStatus.Accepted => "ACCEPTED", + AttendeeStatus.Declined => "DECLINED", + AttendeeStatus.Tentative => "TENTATIVE", + _ => "NEEDS-ACTION" + }; + + var cn = string.IsNullOrWhiteSpace(attendee.Name) ? attendee.Email : attendee.Name; + lines.Add($"ATTENDEE;CN={EscapeIcs(cn)};ROLE={role};PARTSTAT={partStat}:mailto:{EscapeIcs(attendee.Email)}"); + } + } + + lines.Add("END:VEVENT"); + lines.Add("END:VCALENDAR"); + + return string.Join(Environment.NewLine, lines); + } + + private static string EscapeIcs(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + return value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace(";", "\\;", StringComparison.Ordinal) + .Replace(",", "\\,", StringComparison.Ordinal) + .Replace("\r\n", "\\n", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal); + } + + private static string MapStatus(CalendarItemStatus status) + { + return status switch + { + CalendarItemStatus.Cancelled => "CANCELLED", + CalendarItemStatus.Tentative => "TENTATIVE", + _ => "CONFIRMED" + }; + } + + private static string MapVisibility(CalendarItemVisibility visibility) + { + return visibility switch + { + CalendarItemVisibility.Public => "PUBLIC", + CalendarItemVisibility.Private => "PRIVATE", + CalendarItemVisibility.Confidential => "CONFIDENTIAL", + _ => "PUBLIC" + }; + } + public Task StartIdleClientAsync() { if (IsDisposing) diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 102c57ef..b93924b1 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -36,6 +36,7 @@ using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Extensions; using Wino.Core.Http; using Wino.Core.Integration.Processors; +using Wino.Core.Misc; using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Calendar; using Wino.Core.Requests.Folder; @@ -2109,6 +2110,11 @@ public class OutlookSynchronizer : WinoSynchronizer( + localCalendars + .Select(a => a.BackgroundColorHex) + .Where(a => !string.IsNullOrWhiteSpace(a)), + StringComparer.OrdinalIgnoreCase); List insertedCalendars = new(); List updatedCalendars = new(); @@ -2138,8 +2144,12 @@ public class OutlookSynchronizer : WinoSynchronizer MailDialogService.ShowAccountProviderSelectionDialogAsync(providers)); if (accountCreationDialogResult != null) { @@ -104,35 +111,61 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel if (accountCreationDialogResult.ProviderType == MailProviderType.IMAP4) { - var completionSource = new TaskCompletionSource(); - var setupContext = ImapCalDavSettingsNavigationContext.CreateForCreateMode(accountCreationDialogResult, completionSource); + if (createdAccount.SpecialImapProvider == SpecialImapProvider.iCloud || createdAccount.SpecialImapProvider == SpecialImapProvider.Yahoo) + { + var accountCreationCancellationTokenSource = new CancellationTokenSource(); + creationDialog = MailDialogService.GetAccountCreationDialog(accountCreationDialogResult); - Messenger.Send(new BreadcrumbNavigationRequested( - Translator.ImapCalDavSettingsPage_TitleCreate, - WinoPage.ImapCalDavSettingsPage, - setupContext)); + await ExecuteUIThreadTaskAsync(() => creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource)); + await Task.Delay(500); - var setupResult = await completionSource.Task.ConfigureAwait(false) - ?? throw new AccountSetupCanceledException(); + await ExecuteUIThread(() => creationDialog.State = AccountCreationDialogState.SigningIn); - customServerInformation = setupResult.ServerInformation ?? throw new AccountSetupCanceledException(); - customServerInformation.Id = Guid.NewGuid(); - customServerInformation.AccountId = createdAccount.Id; + customServerInformation = _specialImapProviderConfigResolver.GetServerInformation(createdAccount, accountCreationDialogResult) + ?? throw new AccountSetupCanceledException(); - createdAccount.Address = setupResult.EmailAddress; - createdAccount.SenderName = setupResult.DisplayName; - createdAccount.IsCalendarAccessGranted = setupResult.IsCalendarAccessGranted; - createdAccount.ServerInformation = customServerInformation; + customServerInformation.Id = Guid.NewGuid(); + customServerInformation.AccountId = createdAccount.Id; + + createdAccount.Address = accountCreationDialogResult.SpecialImapProviderDetails.Address; + createdAccount.SenderName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName; + createdAccount.IsCalendarAccessGranted = customServerInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav; + createdAccount.ServerInformation = customServerInformation; + + await ValidateSpecialImapConnectivityAsync(customServerInformation).ConfigureAwait(false); + } + else + { + var completionSource = new TaskCompletionSource(); + var setupContext = ImapCalDavSettingsNavigationContext.CreateForCreateMode(accountCreationDialogResult, completionSource); + + await ExecuteUIThread(() => Messenger.Send(new BreadcrumbNavigationRequested( + Translator.ImapCalDavSettingsPage_TitleCreate, + WinoPage.ImapCalDavSettingsPage, + setupContext))); + + var setupResult = await completionSource.Task.ConfigureAwait(false) + ?? throw new AccountSetupCanceledException(); + + customServerInformation = setupResult.ServerInformation ?? throw new AccountSetupCanceledException(); + customServerInformation.Id = Guid.NewGuid(); + customServerInformation.AccountId = createdAccount.Id; + + createdAccount.Address = setupResult.EmailAddress; + createdAccount.SenderName = setupResult.DisplayName; + createdAccount.IsCalendarAccessGranted = setupResult.IsCalendarAccessGranted; + createdAccount.ServerInformation = customServerInformation; + } } else { var accountCreationCancellationTokenSource = new CancellationTokenSource(); creationDialog = MailDialogService.GetAccountCreationDialog(accountCreationDialogResult); - await creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource); + await ExecuteUIThreadTaskAsync(() => creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource)); await Task.Delay(500); - creationDialog.State = AccountCreationDialogState.SigningIn; + await ExecuteUIThread(() => creationDialog.State = AccountCreationDialogState.SigningIn); // OAuth authentication is handled here. // Use SynchronizationManager to handle OAuth authentication. @@ -142,7 +175,10 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel createdAccount, createdAccount.ProviderType == MailProviderType.Gmail); - if (creationDialog.State == AccountCreationDialogState.Canceled) + bool creationCanceled = false; + await ExecuteUIThread(() => creationCanceled = creationDialog.State == AccountCreationDialogState.Canceled); + + if (creationCanceled) throw new AccountSetupCanceledException(); // Update account address with authenticated user information @@ -182,7 +218,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel } if (creationDialog != null) - creationDialog.State = AccountCreationDialogState.PreparingFolders; + await ExecuteUIThread(() => creationDialog.State = AccountCreationDialogState.PreparingFolders); var folderSynchronizationResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(createdAccount.Id); @@ -207,10 +243,10 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel } // Send changes to listeners. - ReportUIChange(new AccountCreatedMessage(createdAccount)); + await ExecuteUIThread(() => ReportUIChange(new AccountCreatedMessage(createdAccount))); // Notify success. - DialogService.InfoBarMessage(Translator.Info_AccountCreatedTitle, string.Format(Translator.Info_AccountCreatedMessage, createdAccount.Address), InfoBarMessageType.Success); + await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.Info_AccountCreatedTitle, string.Format(Translator.Info_AccountCreatedMessage, createdAccount.Address), InfoBarMessageType.Success)); } } catch (Exception ex) when (ex.Message.Contains(nameof(GmailServiceDisabledException))) @@ -219,7 +255,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel // Wino can't continue synchronization in this case. // We must notify the user about this and prevent account creation. - DialogService.InfoBarMessage(Translator.GmailServiceDisabled_Title, Translator.GmailServiceDisabled_Message, InfoBarMessageType.Error); + await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.GmailServiceDisabled_Title, Translator.GmailServiceDisabled_Message, InfoBarMessageType.Error)); if (createdAccount != null) { @@ -243,17 +279,17 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel _winoLogger.TrackEvent("IMAP Test Failed", properties); - DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, testClientPoolException.Message, InfoBarMessageType.Error); + await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, testClientPoolException.Message, InfoBarMessageType.Error)); } catch (ImapClientPoolException clientPoolException) when (clientPoolException.InnerException != null) { - DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, clientPoolException.InnerException.Message, InfoBarMessageType.Error); + await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, clientPoolException.InnerException.Message, InfoBarMessageType.Error)); } catch (Exception ex) { Log.Error(ex, "Failed to create account."); - DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, ex.Message, InfoBarMessageType.Error); + await ExecuteUIThread(() => DialogService.InfoBarMessage(Translator.Info_AccountCreationFailedTitle, ex.Message, InfoBarMessageType.Error)); // Delete account in case of failure. if (createdAccount != null) @@ -263,10 +299,115 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel } finally { - creationDialog?.Complete(false); + await ExecuteUIThread(() => { creationDialog?.Complete(false); }); } } + private async Task ValidateSpecialImapConnectivityAsync(CustomServerInformation serverInformation) + { + var connectivityResult = await SynchronizationManager.Instance + .TestImapConnectivityAsync(serverInformation, allowSSLHandshake: false) + .ConfigureAwait(false); + + if (connectivityResult.IsCertificateUIRequired) + { + var certificateMessage = + $"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row0}\n\n" + + $"{Translator.IMAPSetupDialog_CertificateIssuer}: {connectivityResult.CertificateIssuer}\n" + + $"{Translator.IMAPSetupDialog_CertificateValidFrom}: {connectivityResult.CertificateValidFromDateString}\n" + + $"{Translator.IMAPSetupDialog_CertificateValidTo}: {connectivityResult.CertificateExpirationDateString}\n\n" + + $"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row1}"; + + var allowCertificate = await ExecuteUIThreadTaskAsync( + () => MailDialogService.ShowConfirmationDialogAsync(certificateMessage, Translator.GeneralTitle_Warning, Translator.Buttons_Allow)) + .ConfigureAwait(false); + + if (!allowCertificate) + throw new InvalidOperationException(Translator.IMAPSetupDialog_CertificateDenied); + + connectivityResult = await SynchronizationManager.Instance + .TestImapConnectivityAsync(serverInformation, allowSSLHandshake: true) + .ConfigureAwait(false); + } + + if (!connectivityResult.IsSuccess) + throw new InvalidOperationException(connectivityResult.FailedReason ?? Translator.IMAPSetupDialog_ConnectionFailedMessage); + + if (serverInformation.CalendarSupportMode != ImapCalendarSupportMode.CalDav) + return; + + if (string.IsNullOrWhiteSpace(serverInformation.CalDavServiceUrl)) + throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavUrlRequired); + + var settings = new CalDavConnectionSettings + { + ServiceUri = new Uri(serverInformation.CalDavServiceUrl, UriKind.Absolute), + Username = serverInformation.CalDavUsername, + Password = serverInformation.CalDavPassword + }; + + await _calDavClient.DiscoverCalendarsAsync(settings).ConfigureAwait(false); + } + + private async Task ExecuteUIThreadTaskAsync(Func action) + { + if (Dispatcher == null) + { + await action().ConfigureAwait(false); + return; + } + + var completionSource = new TaskCompletionSource(); + + await ExecuteUIThread(() => + { + _ = ExecuteAndCaptureAsync(); + + async Task ExecuteAndCaptureAsync() + { + try + { + await action().ConfigureAwait(false); + completionSource.TrySetResult(null); + } + catch (Exception ex) + { + completionSource.TrySetException(ex); + } + } + }); + + await completionSource.Task.ConfigureAwait(false); + } + + private async Task ExecuteUIThreadTaskAsync(Func> action) + { + if (Dispatcher == null) + return await action().ConfigureAwait(false); + + var completionSource = new TaskCompletionSource(); + + await ExecuteUIThread(() => + { + _ = ExecuteAndCaptureAsync(); + + async Task ExecuteAndCaptureAsync() + { + try + { + var result = await action().ConfigureAwait(false); + completionSource.TrySetResult(result); + } + catch (Exception ex) + { + completionSource.TrySetException(ex); + } + } + }); + + return await completionSource.Task.ConfigureAwait(false); + } + [RelayCommand] private void EditMergedAccounts(MergedAccountProviderDetailViewModel mergedAccountProviderDetailViewModel) { diff --git a/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml b/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml index 0ca9a2cd..e1bf4a50 100644 --- a/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml +++ b/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml @@ -105,6 +105,7 @@ + @@ -142,8 +143,14 @@ Header="App-Specific Password" PasswordChanged="ImapPasswordChanged" /> - + + diff --git a/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml.cs index bbb1ee5a..1655a356 100644 --- a/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml.cs +++ b/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml.cs @@ -4,6 +4,7 @@ using System.Linq; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Windows.System; +using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; @@ -24,6 +25,7 @@ public sealed partial class NewAccountDialog : ContentDialog public static readonly DependencyProperty IsSpecialImapServerPartVisibleProperty = DependencyProperty.Register(nameof(IsSpecialImapServerPartVisible), typeof(bool), typeof(NewAccountDialog), new PropertyMetadata(false)); public static readonly DependencyProperty SelectedMailProviderProperty = DependencyProperty.Register(nameof(SelectedMailProvider), typeof(ProviderDetail), typeof(NewAccountDialog), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedProviderChanged))); public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(AppColorViewModel), typeof(NewAccountDialog), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedColorChanged))); + public static readonly DependencyProperty SelectedCalendarModeIndexProperty = DependencyProperty.Register(nameof(SelectedCalendarModeIndex), typeof(int), typeof(NewAccountDialog), new PropertyMetadata(0)); public AppColorViewModel SelectedColor @@ -32,6 +34,12 @@ public sealed partial class NewAccountDialog : ContentDialog set { SetValue(SelectedColorProperty, value); } } + public int SelectedCalendarModeIndex + { + get { return (int)GetValue(SelectedCalendarModeIndexProperty); } + set { SetValue(SelectedCalendarModeIndexProperty, value); } + } + /// /// Gets or sets current selected mail provider in the dialog. /// @@ -59,6 +67,12 @@ public sealed partial class NewAccountDialog : ContentDialog public List Providers { get; set; } public List AvailableColors { get; set; } + public List CalendarModeOptions { get; } = + [ + Translator.ImapCalDavSettingsPage_CalendarModeCalDav, + Translator.ImapCalDavSettingsPage_CalendarModeLocalOnly, + Translator.ImapCalDavSettingsPage_CalendarModeDisabled + ]; public AccountCreationDialogResult Result = null; @@ -102,8 +116,19 @@ public sealed partial class NewAccountDialog : ContentDialog if (IsSpecialImapServerPartVisible) { // Special imap detail input. + var calendarSupportMode = SelectedCalendarModeIndex switch + { + 1 => ImapCalendarSupportMode.LocalOnly, + 2 => ImapCalendarSupportMode.Disabled, + _ => ImapCalendarSupportMode.CalDav + }; - var details = new SpecialImapProviderDetails(SpecialImapAddress.Text.Trim(), AppSpecificPassword.Password.Trim(), DisplayNameTextBox.Text.Trim(), SelectedMailProvider.SpecialImapProvider); + var details = new SpecialImapProviderDetails( + SpecialImapAddress.Text.Trim(), + AppSpecificPassword.Password.Trim(), + DisplayNameTextBox.Text.Trim(), + SelectedMailProvider.SpecialImapProvider, + calendarSupportMode); Result = new AccountCreationDialogResult(SelectedMailProvider.Type, AccountNameTextbox.Text.Trim(), details, SelectedColor?.Hex ?? string.Empty); Hide(); diff --git a/Wino.Mail.WinUI/Views/Account/ImapCalDavSettingsPage.xaml b/Wino.Mail.WinUI/Views/Account/ImapCalDavSettingsPage.xaml index dfe716a2..f6713720 100644 --- a/Wino.Mail.WinUI/Views/Account/ImapCalDavSettingsPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/ImapCalDavSettingsPage.xaml @@ -3,20 +3,29 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:abstract="using:Wino.Views.Abstract" - xmlns:helpers="using:Wino.Helpers" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:helpers="using:Wino.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - + - + - + @@ -32,15 +41,29 @@ CornerRadius="12" Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(ViewModel.IsAdvancedSetupSelected), Mode=OneWay}"> - - + + - - + + @@ -59,7 +82,10 @@ CornerRadius="12" Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(ViewModel.IsBasicSetupSelected), Mode=OneWay}"> - + - - + + - +