From acf0f649e871e342c700211a35c1f696f42d6b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sun, 15 Feb 2026 02:20:18 +0100 Subject: [PATCH] CalDav synchronizer, new IMAP setup/edit page. --- .../Shared/CustomServerInformation.cs | 7 + .../Enums/ImapCalendarSupportMode.cs | 8 + Wino.Core.Domain/Enums/WinoPage.cs | 1 + .../Interfaces/IAutoDiscoveryService.cs | 13 +- Wino.Core.Domain/Interfaces/ICalDavClient.cs | 22 + .../Interfaces/ICalendarIcsFileService.cs | 14 + .../Interfaces/IImapAccountCreationDialog.cs | 24 - .../AutoDiscovery/AutoDiscoverySettings.cs | 4 +- .../Models/Calendar/CalDavCalendar.cs | 10 + .../Models/Calendar/CalDavCalendarEvent.cs | 55 ++ .../Calendar/CalDavConnectionSettings.cs | 11 + .../Translations/en_US/resources.json | 41 + .../Helpers/InMemoryDatabaseService.cs | 47 +- .../Services/AutoDiscoveryServiceTests.cs | 221 +++++ .../Services/CalendarServiceTests.cs | 75 +- .../Synchronizers/ICloudCalDavLiveTests.cs | 98 ++ .../Synchronizers/ImapClientPoolTests.cs | 50 - ...mapSynchronizerCalDavConfigurationTests.cs | 172 ++++ .../ImapSynchronizerIdleTests.cs | 4 +- Wino.Core/Integration/ImapClientPool.cs | 101 +- .../Processors/DefaultChangeProcessor.cs | 10 +- .../Processors/ImapChangeProcessor.cs | 138 ++- Wino.Core/Services/AutoDiscoveryService.cs | 694 ++++++++++++- Wino.Core/Services/SynchronizerFactory.cs | 10 +- Wino.Core/Synchronizers/ImapSynchronizer.cs | 320 +++++- .../AccountDetailsPageViewModel.cs | 12 +- .../AccountManagementViewModel.cs | 111 +-- .../ImapCalDavSettingsNavigationContext.cs | 52 + .../ImapCalDavSettingsPageViewModel.cs | 926 ++++++++++++++++++ Wino.Mail.WinUI/App.xaml.cs | 1 + .../Dialogs/NewImapSetupDialog.xaml | 25 - .../Dialogs/NewImapSetupDialog.xaml.cs | 122 --- Wino.Mail.WinUI/Services/DialogService.cs | 27 - Wino.Mail.WinUI/Services/NavigationService.cs | 1 + .../Styles/WinoCalendarResources.xaml | 2 +- .../ImapCalDavSettingsPageAbstract.cs | 8 + .../Views/Account/ImapCalDavSettingsPage.xaml | 171 ++++ .../Account/ImapCalDavSettingsPage.xaml.cs | 31 + .../ImapSetup/AdvancedImapSetupPage.xaml | 300 ------ .../ImapSetup/AdvancedImapSetupPage.xaml.cs | 282 ------ .../ImapSetup/ImapConnectionFailedPage.xaml | 72 -- .../ImapConnectionFailedPage.xaml.cs | 49 - .../ImapSetup/PreparingImapFoldersPage.xaml | 31 - .../PreparingImapFoldersPage.xaml.cs | 29 - .../ImapSetup/TestingImapConnectionPage.xaml | 104 -- .../TestingImapConnectionPage.xaml.cs | 116 --- .../Views/ImapSetup/WelcomeImapSetupPage.xaml | 118 --- .../ImapSetup/WelcomeImapSetupPage.xaml.cs | 96 -- .../Settings/EditAccountDetailsPage.xaml | 1 - Wino.Mail.WinUI/Wino.Mail.WinUI.csproj | 21 +- .../Mails/ImapSetupBackNavigationRequested.cs | 11 - .../Client/Mails/ImapSetupDismissRequested.cs | 9 - .../Mails/ImapSetupNavigationRequested.cs | 10 - Wino.Services/CalDavClient.cs | 676 +++++++++++++ Wino.Services/CalendarIcsFileService.cs | 127 +++ Wino.Services/CalendarService.cs | 2 + Wino.Services/DatabaseService.cs | 30 + Wino.Services/ServicesContainerSetup.cs | 2 + 58 files changed, 3993 insertions(+), 1732 deletions(-) create mode 100644 Wino.Core.Domain/Enums/ImapCalendarSupportMode.cs create mode 100644 Wino.Core.Domain/Interfaces/ICalDavClient.cs create mode 100644 Wino.Core.Domain/Interfaces/ICalendarIcsFileService.cs delete mode 100644 Wino.Core.Domain/Interfaces/IImapAccountCreationDialog.cs create mode 100644 Wino.Core.Domain/Models/Calendar/CalDavCalendar.cs create mode 100644 Wino.Core.Domain/Models/Calendar/CalDavCalendarEvent.cs create mode 100644 Wino.Core.Domain/Models/Calendar/CalDavConnectionSettings.cs create mode 100644 Wino.Core.Tests/Services/AutoDiscoveryServiceTests.cs create mode 100644 Wino.Core.Tests/Synchronizers/ICloudCalDavLiveTests.cs create mode 100644 Wino.Core.Tests/Synchronizers/ImapSynchronizerCalDavConfigurationTests.cs create mode 100644 Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs create mode 100644 Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs delete mode 100644 Wino.Mail.WinUI/Dialogs/NewImapSetupDialog.xaml delete mode 100644 Wino.Mail.WinUI/Dialogs/NewImapSetupDialog.xaml.cs create mode 100644 Wino.Mail.WinUI/Views/Abstract/ImapCalDavSettingsPageAbstract.cs create mode 100644 Wino.Mail.WinUI/Views/Account/ImapCalDavSettingsPage.xaml create mode 100644 Wino.Mail.WinUI/Views/Account/ImapCalDavSettingsPage.xaml.cs delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/AdvancedImapSetupPage.xaml delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/AdvancedImapSetupPage.xaml.cs delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/ImapConnectionFailedPage.xaml delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/ImapConnectionFailedPage.xaml.cs delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/PreparingImapFoldersPage.xaml delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/PreparingImapFoldersPage.xaml.cs delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/TestingImapConnectionPage.xaml delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/TestingImapConnectionPage.xaml.cs delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/WelcomeImapSetupPage.xaml delete mode 100644 Wino.Mail.WinUI/Views/ImapSetup/WelcomeImapSetupPage.xaml.cs delete mode 100644 Wino.Messages/Client/Mails/ImapSetupBackNavigationRequested.cs delete mode 100644 Wino.Messages/Client/Mails/ImapSetupDismissRequested.cs delete mode 100644 Wino.Messages/Client/Mails/ImapSetupNavigationRequested.cs create mode 100644 Wino.Services/CalDavClient.cs create mode 100644 Wino.Services/CalendarIcsFileService.cs diff --git a/Wino.Core.Domain/Entities/Shared/CustomServerInformation.cs b/Wino.Core.Domain/Entities/Shared/CustomServerInformation.cs index 00aee83b..555fb64d 100644 --- a/Wino.Core.Domain/Entities/Shared/CustomServerInformation.cs +++ b/Wino.Core.Domain/Entities/Shared/CustomServerInformation.cs @@ -30,6 +30,11 @@ public class CustomServerInformation public string OutgoingServerUsername { get; set; } public string OutgoingServerPassword { get; set; } + public string CalDavServiceUrl { get; set; } + public string CalDavUsername { get; set; } + public string CalDavPassword { get; set; } + public ImapCalendarSupportMode CalendarSupportMode { get; set; } + /// /// useSSL True: SslOnConnect /// useSSL False: StartTlsWhenAvailable @@ -65,6 +70,8 @@ public class CustomServerInformation { "OutgoingServerPort", OutgoingServerPort }, { "OutgoingServerSocketOption", OutgoingServerSocketOption.ToString() }, { "OutgoingAuthenticationMethod", OutgoingAuthenticationMethod.ToString() }, + { "CalendarSupportMode", CalendarSupportMode.ToString() }, + { "CalDavServiceUrl", CalDavServiceUrl }, { "ProxyServer", ProxyServer }, { "ProxyServerPort", ProxyServerPort } }; diff --git a/Wino.Core.Domain/Enums/ImapCalendarSupportMode.cs b/Wino.Core.Domain/Enums/ImapCalendarSupportMode.cs new file mode 100644 index 00000000..4e082a87 --- /dev/null +++ b/Wino.Core.Domain/Enums/ImapCalendarSupportMode.cs @@ -0,0 +1,8 @@ +namespace Wino.Core.Domain.Enums; + +public enum ImapCalendarSupportMode +{ + Disabled = 0, + CalDav = 1, + LocalOnly = 2 +} diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs index 56bc5ce3..2787ba3f 100644 --- a/Wino.Core.Domain/Enums/WinoPage.cs +++ b/Wino.Core.Domain/Enums/WinoPage.cs @@ -27,6 +27,7 @@ public enum WinoPage SettingOptionsPage, AliasManagementPage, EditAccountDetailsPage, + ImapCalDavSettingsPage, KeyboardShortcutsPage, CalendarPage, CalendarSettingsPage, diff --git a/Wino.Core.Domain/Interfaces/IAutoDiscoveryService.cs b/Wino.Core.Domain/Interfaces/IAutoDiscoveryService.cs index 0ba6642a..3f49c0bf 100644 --- a/Wino.Core.Domain/Interfaces/IAutoDiscoveryService.cs +++ b/Wino.Core.Domain/Interfaces/IAutoDiscoveryService.cs @@ -1,17 +1,22 @@ -using System.Threading.Tasks; +using System; +using System.Threading; +using System.Threading.Tasks; using Wino.Core.Domain.Models.AutoDiscovery; namespace Wino.Core.Domain.Interfaces; /// -/// Searches for Auto Discovery settings for custom mail accounts. +/// Searches for auto-discovery settings for custom mail accounts. /// public interface IAutoDiscoveryService { /// /// Tries to return the best mail server settings using different techniques. /// - /// Address to search settings for. - /// CustomServerInformation with only settings applied. Task GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings); + + /// + /// Tries to resolve a CalDAV endpoint for the mailbox address. + /// + Task DiscoverCalDavServiceUriAsync(string mailAddress, CancellationToken cancellationToken = default); } diff --git a/Wino.Core.Domain/Interfaces/ICalDavClient.cs b/Wino.Core.Domain/Interfaces/ICalDavClient.cs new file mode 100644 index 00000000..cb28f73f --- /dev/null +++ b/Wino.Core.Domain/Interfaces/ICalDavClient.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Wino.Core.Domain.Models.Calendar; + +namespace Wino.Core.Domain.Interfaces; + +public interface ICalDavClient +{ + Task> DiscoverCalendarsAsync( + CalDavConnectionSettings connectionSettings, + CancellationToken cancellationToken = default); + + Task> GetCalendarEventsAsync( + CalDavConnectionSettings connectionSettings, + CalDavCalendar calendar, + DateTimeOffset startUtc, + DateTimeOffset endUtc, + CancellationToken cancellationToken = default); +} + diff --git a/Wino.Core.Domain/Interfaces/ICalendarIcsFileService.cs b/Wino.Core.Domain/Interfaces/ICalendarIcsFileService.cs new file mode 100644 index 00000000..2bf3efbb --- /dev/null +++ b/Wino.Core.Domain/Interfaces/ICalendarIcsFileService.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading.Tasks; + +namespace Wino.Core.Domain.Interfaces; + +/// +/// Persists CalDAV ICS payloads on disk for IMAP accounts. +/// +public interface ICalendarIcsFileService +{ + Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent); + Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId); + Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId); +} diff --git a/Wino.Core.Domain/Interfaces/IImapAccountCreationDialog.cs b/Wino.Core.Domain/Interfaces/IImapAccountCreationDialog.cs deleted file mode 100644 index 21d9fb47..00000000 --- a/Wino.Core.Domain/Interfaces/IImapAccountCreationDialog.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Threading.Tasks; -using Wino.Core.Domain.Entities.Shared; - -namespace Wino.Core.Domain.Interfaces; - -public interface IImapAccountCreationDialog : IAccountCreationDialog -{ - /// - /// Returns the custom server information from the dialog.. - /// - /// Null if canceled. - Task GetCustomServerInformationAsync(); - - /// - /// Displays preparing folders page. - /// - void ShowPreparingFolders(); - - /// - /// Updates account properties for the welcome imap setup dialog and starts the setup. - /// - /// Account properties. - void StartImapConnectionSetup(MailAccount account); -} diff --git a/Wino.Core.Domain/Models/AutoDiscovery/AutoDiscoverySettings.cs b/Wino.Core.Domain/Models/AutoDiscovery/AutoDiscoverySettings.cs index 87494dcc..793edad9 100644 --- a/Wino.Core.Domain/Models/AutoDiscovery/AutoDiscoverySettings.cs +++ b/Wino.Core.Domain/Models/AutoDiscovery/AutoDiscoverySettings.cs @@ -64,8 +64,8 @@ public class AutoDiscoverySettings } public AutoDiscoveryProviderSetting GetImapSettings() - => Settings?.Find(a => a.Protocol == "IMAP"); + => Settings?.Find(a => string.Equals(a.Protocol, "IMAP", StringComparison.OrdinalIgnoreCase)); public AutoDiscoveryProviderSetting GetSmptpSettings() - => Settings?.Find(a => a.Protocol == "SMTP"); + => Settings?.Find(a => string.Equals(a.Protocol, "SMTP", StringComparison.OrdinalIgnoreCase)); } diff --git a/Wino.Core.Domain/Models/Calendar/CalDavCalendar.cs b/Wino.Core.Domain/Models/Calendar/CalDavCalendar.cs new file mode 100644 index 00000000..edf1890e --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/CalDavCalendar.cs @@ -0,0 +1,10 @@ +namespace Wino.Core.Domain.Models.Calendar; + +public sealed class CalDavCalendar +{ + public string RemoteCalendarId { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string CTag { get; init; } = string.Empty; + public string SyncToken { get; init; } = string.Empty; +} + diff --git a/Wino.Core.Domain/Models/Calendar/CalDavCalendarEvent.cs b/Wino.Core.Domain/Models/Calendar/CalDavCalendarEvent.cs new file mode 100644 index 00000000..d77164ef --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/CalDavCalendarEvent.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Calendar; + +public sealed class CalDavCalendarEvent +{ + public string RemoteEventId { get; init; } = string.Empty; + public string RemoteResourceHref { get; init; } = string.Empty; + public string ETag { get; init; } = string.Empty; + public string IcsContent { get; init; } = string.Empty; + + public string Uid { get; init; } = string.Empty; + public string SeriesMasterRemoteEventId { get; init; } = string.Empty; + public bool IsSeriesMaster { get; init; } + public bool IsRecurringInstance { get; init; } + + public string Title { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string Location { get; init; } = string.Empty; + + public DateTimeOffset Start { get; init; } + public DateTimeOffset End { get; init; } + public string StartTimeZone { get; init; } = string.Empty; + public string EndTimeZone { get; init; } = string.Empty; + public string Recurrence { get; init; } = string.Empty; + + public string OrganizerDisplayName { get; init; } = string.Empty; + public string OrganizerEmail { get; init; } = string.Empty; + + public CalendarItemStatus Status { get; init; } = CalendarItemStatus.Accepted; + public CalendarItemVisibility Visibility { get; init; } = CalendarItemVisibility.Default; + public CalendarItemShowAs ShowAs { get; init; } = CalendarItemShowAs.Busy; + public bool IsHidden { get; init; } + + public IReadOnlyList Attendees { get; init; } = []; + public IReadOnlyList Reminders { get; init; } = []; +} + +public sealed class CalDavEventAttendee +{ + public string Name { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; + public AttendeeStatus AttendenceStatus { get; init; } = AttendeeStatus.NeedsAction; + public bool IsOrganizer { get; init; } + public bool IsOptionalAttendee { get; init; } +} + +public sealed class CalDavEventReminder +{ + public int DurationInSeconds { get; init; } + public CalendarItemReminderType ReminderType { get; init; } = CalendarItemReminderType.Popup; +} + diff --git a/Wino.Core.Domain/Models/Calendar/CalDavConnectionSettings.cs b/Wino.Core.Domain/Models/Calendar/CalDavConnectionSettings.cs new file mode 100644 index 00000000..3b9c874f --- /dev/null +++ b/Wino.Core.Domain/Models/Calendar/CalDavConnectionSettings.cs @@ -0,0 +1,11 @@ +using System; + +namespace Wino.Core.Domain.Models.Calendar; + +public sealed class CalDavConnectionSettings +{ + public Uri ServiceUri { get; init; } + public string Username { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; +} + diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 49b27e33..00f88c28 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -376,6 +376,47 @@ "IMAPSetupDialog_Username": "Username", "IMAPSetupDialog_UsernamePlaceholder": "johndoe, johndoe@fabrikam.com, domain/johndoe", "IMAPSetupDialog_UseSameConfig": "Use the same username and password for sending email", + "ImapCalDavSettingsPage_TitleCreate": "IMAP and Calendar Setup", + "ImapCalDavSettingsPage_TitleEdit": "Edit IMAP and Calendar Settings", + "ImapCalDavSettingsPage_Subtitle": "Configure IMAP/SMTP and optional calendar synchronization for this account.", + "ImapCalDavSettingsPage_BasicSectionTitle": "Basic setup", + "ImapCalDavSettingsPage_BasicSectionDescription": "Enter your identity and credentials. Wino can try to detect server settings automatically.", + "ImapCalDavSettingsPage_BasicTab": "Basic", + "ImapCalDavSettingsPage_EnableCalendarSupport": "Enable calendar support", + "ImapCalDavSettingsPage_AutoDiscoverButton": "Autodiscover mail settings", + "ImapCalDavSettingsPage_AutoDiscoverySuccessMessage": "Mail settings discovered and applied.", + "ImapCalDavSettingsPage_AdvancedSectionTitle": "Advanced configuration", + "ImapCalDavSettingsPage_AdvancedSectionDescription": "Enter server settings manually if autodiscovery is unavailable or incorrect.", + "ImapCalDavSettingsPage_AdvancedTab": "Advanced", + "ImapCalDavSettingsPage_CalendarSectionTitle": "Calendar setup", + "ImapCalDavSettingsPage_CalendarSectionDescription": "Choose how calendar data should work for this IMAP account.", + "ImapCalDavSettingsPage_CalendarModeHeader": "Calendar mode", + "ImapCalDavSettingsPage_ConnectionSecurityHeader": "Connection security", + "ImapCalDavSettingsPage_AuthenticationMethodHeader": "Authentication method", + "ImapCalDavSettingsPage_CalendarModeDisabled": "Disabled", + "ImapCalDavSettingsPage_CalendarModeCalDav": "CalDAV synchronization", + "ImapCalDavSettingsPage_CalendarModeLocalOnly": "Local calendar only", + "ImapCalDavSettingsPage_CalendarModeDisabledDescription": "Calendar is disabled for this account.", + "ImapCalDavSettingsPage_CalendarModeCalDavDescription": "Calendar items are synchronized with your CalDAV server.", + "ImapCalDavSettingsPage_CalendarModeLocalOnlyDescription": "Calendar items are stored only on this computer and are not synchronized to the network.", + "ImapCalDavSettingsPage_LocalCalendarLearnMore": "How local calendar works", + "ImapCalDavSettingsPage_LocalCalendarDialogTitle": "Local calendar only", + "ImapCalDavSettingsPage_LocalCalendarDialogMessage": "Local calendar keeps all events only on your computer. Nothing is synchronized to iCloud, Yahoo, or any other provider.", + "ImapCalDavSettingsPage_CalDavServiceUrl": "CalDAV service URL", + "ImapCalDavSettingsPage_CalDavUsername": "CalDAV username", + "ImapCalDavSettingsPage_CalDavPassword": "CalDAV password", + "ImapCalDavSettingsPage_CalDavNotRequiredMessage": "CalDAV test is only required when calendar mode is set to CalDAV synchronization.", + "ImapCalDavSettingsPage_CalDavUrlRequired": "CalDAV service URL is required.", + "ImapCalDavSettingsPage_CalDavUrlInvalid": "CalDAV service URL must be an absolute URL.", + "ImapCalDavSettingsPage_CalDavUsernameRequired": "CalDAV username is required.", + "ImapCalDavSettingsPage_CalDavPasswordRequired": "CalDAV password is required.", + "ImapCalDavSettingsPage_TestImapButton": "Test IMAP connection", + "ImapCalDavSettingsPage_TestCalDavButton": "Test CalDAV connection", + "ImapCalDavSettingsPage_ImapTestSuccessMessage": "IMAP connection test succeeded.", + "ImapCalDavSettingsPage_CalDavTestSuccessMessage": "CalDAV connection test succeeded.", + "ImapCalDavSettingsPage_SaveSuccessMessage": "Account settings validated and saved.", + "ImapCalDavSettingsPage_ICloudHint": "Use an app-specific password generated from your Apple account settings.", + "ImapCalDavSettingsPage_YahooHint": "Use an app password from your Yahoo account security settings.", "Info_AccountCreatedMessage": "{0} is created", "Info_AccountCreatedTitle": "Account Creation", "Info_AccountCreationFailedTitle": "Account Creation Failed", diff --git a/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs index 79df91c5..e952a96a 100644 --- a/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs +++ b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs @@ -1,4 +1,5 @@ using SQLite; +using System.IO; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; @@ -12,12 +13,14 @@ namespace Wino.Core.Tests.Helpers; /// public class InMemoryDatabaseService : IDatabaseService { + private readonly string _databasePath; public SQLiteAsyncConnection Connection { get; private set; } public InMemoryDatabaseService() { - // Use :memory: for a truly in-memory database or a temporary file - Connection = new SQLiteAsyncConnection(":memory:"); + // Use a unique temporary file per test instance for stable async access. + _databasePath = Path.Combine(Path.GetTempPath(), $"wino-tests-{Guid.NewGuid():N}.db"); + Connection = new SQLiteAsyncConnection(_databasePath); } public async Task InitializeAsync() @@ -27,23 +30,24 @@ public class InMemoryDatabaseService : IDatabaseService private async Task CreateTablesAsync() { - await Task.WhenAll( - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync(), - Connection.CreateTableAsync() - ); + // Keep table creation sequential for in-memory SQLite to avoid connection contention. + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); } public async ValueTask DisposeAsync() @@ -53,5 +57,10 @@ public class InMemoryDatabaseService : IDatabaseService await Connection.CloseAsync(); Connection = null!; } + + if (File.Exists(_databasePath)) + { + File.Delete(_databasePath); + } } } diff --git a/Wino.Core.Tests/Services/AutoDiscoveryServiceTests.cs b/Wino.Core.Tests/Services/AutoDiscoveryServiceTests.cs new file mode 100644 index 00000000..fd1dd8c1 --- /dev/null +++ b/Wino.Core.Tests/Services/AutoDiscoveryServiceTests.cs @@ -0,0 +1,221 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using FluentAssertions; +using Wino.Core.Domain.Models.AutoDiscovery; +using Wino.Core.Services; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public class AutoDiscoveryServiceTests +{ + [Fact] + public async Task GetAutoDiscoverySettings_UsesThunderbirdAutoconfig_WhenAvailable() + { + var handler = new StubHttpMessageHandler(request => + { + var uri = request.RequestUri!.ToString(); + + if (uri.StartsWith("https://autoconfig.example.com/mail/config-v1.1.xml", StringComparison.OrdinalIgnoreCase)) + { + return CreateXmlResponse(""" + + + + imap.example.com + 993 + SSL + %EMAILLOCALPART% + + + smtp.example.com + 587 + STARTTLS + %EMAILADDRESS% + + + + """, request); + } + + return CreateStatusResponse(HttpStatusCode.NotFound, request); + }); + + using var client = new HttpClient(handler); + var sut = new AutoDiscoveryService(client); + + var settings = await sut.GetAutoDiscoverySettings(new AutoDiscoveryMinimalSettings + { + Email = "user@example.com", + DisplayName = "User", + Password = "secret" + }); + + settings.Should().NotBeNull(); + settings!.Domain.Should().Be("example.com"); + settings.GetImapSettings()!.Address.Should().Be("imap.example.com"); + settings.GetImapSettings()!.Username.Should().Be("user"); + settings.GetSmptpSettings()!.Address.Should().Be("smtp.example.com"); + settings.GetSmptpSettings()!.Username.Should().Be("user@example.com"); + handler.RequestedUris.Should().NotContain(uri => uri.Contains("emailsettings.firetrust.com", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetAutoDiscoverySettings_FallsBackToFiretrust_WhenThunderbirdMethodsFail() + { + var handler = new StubHttpMessageHandler(request => + { + var uri = request.RequestUri!.ToString(); + + if (uri.StartsWith("https://emailsettings.firetrust.com/settings?q=", StringComparison.OrdinalIgnoreCase)) + { + return CreateJsonResponse(""" + { + "domain": "example.com", + "settings": [ + { + "protocol": "IMAP", + "address": "imap.firetrust.example.com", + "port": 993, + "secure": "SSL", + "username": "user@example.com" + }, + { + "protocol": "SMTP", + "address": "smtp.firetrust.example.com", + "port": 587, + "secure": "STARTTLS", + "username": "user@example.com" + } + ] + } + """, request); + } + + if (uri.StartsWith("https://dns.google/resolve", StringComparison.OrdinalIgnoreCase)) + { + return CreateJsonResponse("{\"Status\":0}", request); + } + + return CreateStatusResponse(HttpStatusCode.NotFound, request); + }); + + using var client = new HttpClient(handler); + var sut = new AutoDiscoveryService(client); + + var settings = await sut.GetAutoDiscoverySettings(new AutoDiscoveryMinimalSettings + { + Email = "user@example.com" + }); + + settings.Should().NotBeNull(); + settings!.GetImapSettings()!.Address.Should().Be("imap.firetrust.example.com"); + settings.GetSmptpSettings()!.Address.Should().Be("smtp.firetrust.example.com"); + handler.RequestedUris.Should().Contain(uri => uri.Contains("emailsettings.firetrust.com", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task DiscoverCalDavServiceUriAsync_ReturnsKnownYahooEndpoint() + { + var sut = new AutoDiscoveryService(new HttpClient(new StubHttpMessageHandler(_ => throw new InvalidOperationException("No network call expected")))); + + var uri = await sut.DiscoverCalDavServiceUriAsync("user@yahoo.com"); + + uri.Should().Be(new Uri("https://caldav.calendar.yahoo.com/")); + } + + [Fact] + public async Task DiscoverCalDavServiceUriAsync_ResolvesWellKnownRedirect() + { + var handler = new StubHttpMessageHandler(request => + { + var uri = request.RequestUri!.ToString(); + + if (uri.Equals("https://calendar.example.com/.well-known/caldav", StringComparison.OrdinalIgnoreCase)) + { + var response = CreateStatusResponse(HttpStatusCode.Found, request); + response.Headers.Location = new Uri("https://dav.example.net/caldav/"); + return response; + } + + return CreateStatusResponse(HttpStatusCode.NotFound, request); + }); + + using var client = new HttpClient(handler); + var sut = new AutoDiscoveryService(client); + + var uri = await sut.DiscoverCalDavServiceUriAsync("user@calendar.example.com"); + + uri.Should().Be(new Uri("https://dav.example.net/caldav/")); + } + + private static HttpResponseMessage CreateXmlResponse(string xml, HttpRequestMessage request) + => new(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(xml, Encoding.UTF8, "application/xml") + }; + + private static HttpResponseMessage CreateJsonResponse(string json, HttpRequestMessage request) + => new(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private static HttpResponseMessage CreateStatusResponse(HttpStatusCode statusCode, HttpRequestMessage request) + => new(statusCode) + { + RequestMessage = request + }; + + private sealed class StubHttpMessageHandler(Func responseFactory) : HttpMessageHandler + { + private readonly Func _responseFactory = responseFactory; + + public List RequestedUris { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestedUris.Add(request.RequestUri?.ToString() ?? string.Empty); + var response = _responseFactory(request); + + // Ensure probing logic sees a request URI even if response factory forgot it. + response.RequestMessage ??= request; + + if (response.Headers.Date == null) + { + response.Headers.Date = DateTimeOffset.UtcNow; + } + + if (!response.Headers.Contains("DAV") && + response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + response.Headers.Add("DAV", "1, calendar-access"); + } + + var hasAllowHeader = response.Headers.TryGetValues("Allow", out var allowValues) && + allowValues.Any(); + + if (!hasAllowHeader && + response.StatusCode == HttpStatusCode.OK && + request.Method == HttpMethod.Options) + { + response.Headers.Add("Allow", "PROPFIND"); + } + + if (response.Content == null) + { + response.Content = new StringContent(string.Empty, Encoding.UTF8, "text/plain"); + } + + if (response.Content.Headers.ContentType == null) + { + response.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + } + + return Task.FromResult(response); + } + } +} diff --git a/Wino.Core.Tests/Services/CalendarServiceTests.cs b/Wino.Core.Tests/Services/CalendarServiceTests.cs index d8dd3575..91df77c0 100644 --- a/Wino.Core.Tests/Services/CalendarServiceTests.cs +++ b/Wino.Core.Tests/Services/CalendarServiceTests.cs @@ -117,79 +117,6 @@ public class CalendarServiceTests : IAsyncLifetime result.Should().BeEmpty(); } - [Fact] - public async Task GetCalendarEventsAsync_WithRecurringEventInstances_ReturnsAllInstancesInPeriod() - { - // Arrange - Simulate synced recurring event instances (as they would come from server) - var parentId = Guid.NewGuid(); - var startDate1 = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc); - var startDate2 = new DateTime(2025, 1, 16, 10, 0, 0, DateTimeKind.Utc); - var startDate3 = new DateTime(2025, 1, 17, 10, 0, 0, DateTimeKind.Utc); - - // Parent series master (typically hidden or outside display range) - var parentEvent = new CalendarItem - { - Id = parentId, - Title = "Daily Standup", - Description = "Daily team sync", - StartDate = startDate1, - DurationInSeconds = 1800, // 30 minutes - CalendarId = _testCalendar.Id, - IsHidden = false, - Recurrence = "RRULE:FREQ=DAILY;COUNT=3" - }; - - // Individual occurrence instances (as synced from server) - var instance1 = new CalendarItem - { - Id = Guid.NewGuid(), - Title = "Daily Standup", - StartDate = startDate1, - DurationInSeconds = 1800, - CalendarId = _testCalendar.Id, - RecurringCalendarItemId = parentId, - IsHidden = false - }; - - var instance2 = new CalendarItem - { - Id = Guid.NewGuid(), - Title = "Daily Standup", - StartDate = startDate2, - DurationInSeconds = 1800, - CalendarId = _testCalendar.Id, - RecurringCalendarItemId = parentId, - IsHidden = false - }; - - var instance3 = new CalendarItem - { - Id = Guid.NewGuid(), - Title = "Daily Standup", - StartDate = startDate3, - DurationInSeconds = 1800, - CalendarId = _testCalendar.Id, - RecurringCalendarItemId = parentId, - IsHidden = false - }; - - await _calendarService.CreateNewCalendarItemAsync(parentEvent, null); - await _calendarService.CreateNewCalendarItemAsync(instance1, null); - await _calendarService.CreateNewCalendarItemAsync(instance2, null); - await _calendarService.CreateNewCalendarItemAsync(instance3, null); - - var period = new TimeRange( - new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc), - new DateTime(2025, 1, 18, 0, 0, 0, DateTimeKind.Utc)); - - // Act - var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period); - - // Assert - Should return parent + 3 instances = 4 total - result.Should().HaveCount(4, "parent event plus 3 instances"); - result.Where(e => e.Title == "Daily Standup").Should().HaveCount(4); - } - [Fact] public async Task GetCalendarEventsAsync_WithHiddenEvent_ExcludesFromResults() { @@ -267,7 +194,7 @@ public class CalendarServiceTests : IAsyncLifetime // Add events to both calendars var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc); - + var event1 = new CalendarItem { Id = Guid.NewGuid(), diff --git a/Wino.Core.Tests/Synchronizers/ICloudCalDavLiveTests.cs b/Wino.Core.Tests/Synchronizers/ICloudCalDavLiveTests.cs new file mode 100644 index 00000000..4205baaf --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/ICloudCalDavLiveTests.cs @@ -0,0 +1,98 @@ +using FluentAssertions; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Models.Calendar; +using Wino.Services; +using Xunit; +using Xunit.Abstractions; + +namespace Wino.Core.Tests.Synchronizers; + +public sealed class ICloudCalDavLiveTests +{ + private const string ManualSkipMessage = "Manual live iCloud CalDAV test. Fill credentials/constants in this file and remove Skip to run."; + + // Inline credentials/configuration (manual test by design). + // For iCloud, ServiceUri is typically https://caldav.icloud.com/ + private const string ServiceUri = "https://caldav.icloud.com/"; + private static readonly CustomServerInformation ServerInformation = new() + { + IncomingServerUsername = "", + IncomingServerPassword = "", + Address = "" + }; + + // Fixed UTC range for deterministic fetch checks. + private static readonly DateTimeOffset PeriodStartUtc = new(2026, 01, 01, 0, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset PeriodEndUtc = new(2026, 12, 31, 23, 59, 59, TimeSpan.Zero); + + private readonly ITestOutputHelper _output; + + public ICloudCalDavLiveTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact(Skip = ManualSkipMessage)] + [Trait("Category", "Live")] + public async Task FetchesEventsForFixedUtcRange_AllCalendars() + { + var client = new CalDavClient(); + var settings = GetConnectionSettings(); + + var calendars = await client.DiscoverCalendarsAsync(settings); + calendars.Should().NotBeNull(); + calendars.Should().NotBeEmpty(); + + foreach (var calendar in calendars) + { + var events = await client.GetCalendarEventsAsync(settings, calendar, PeriodStartUtc, PeriodEndUtc); + _output.WriteLine($"Calendar: {calendar.Name} ({calendar.RemoteCalendarId}) => {events.Count} events"); + } + } + + [Fact(Skip = ManualSkipMessage)] + [Trait("Category", "Live")] + public async Task ParsesRequiredIcsFields_ForFetchedEvents() + { + var client = new CalDavClient(); + var settings = GetConnectionSettings(); + + var calendars = await client.DiscoverCalendarsAsync(settings); + calendars.Should().NotBeNull(); + calendars.Should().NotBeEmpty(); + + foreach (var calendar in calendars) + { + var events = await client.GetCalendarEventsAsync(settings, calendar, PeriodStartUtc, PeriodEndUtc); + + foreach (var item in events) + { + item.Uid.Should().NotBeNullOrWhiteSpace(); + item.Start.Should().NotBe(default(DateTimeOffset)); + item.Title.Should().NotBeNull(); + } + } + } + + [Fact] + public void BuildsConnectionSettings_FromCustomServerInformation() + { + var settings = GetConnectionSettings(); + + settings.ServiceUri.Should().Be(new Uri(ServiceUri)); + settings.Username.Should().Be(string.IsNullOrWhiteSpace(ServerInformation.IncomingServerUsername) + ? ServerInformation.Address + : ServerInformation.IncomingServerUsername); + settings.Password.Should().Be(ServerInformation.IncomingServerPassword); + } + + private static CalDavConnectionSettings GetConnectionSettings() + => new() + { + ServiceUri = new Uri(ServiceUri), + Username = string.IsNullOrWhiteSpace(ServerInformation.IncomingServerUsername) + ? ServerInformation.Address + : ServerInformation.IncomingServerUsername, + Password = ServerInformation.IncomingServerPassword + }; +} diff --git a/Wino.Core.Tests/Synchronizers/ImapClientPoolTests.cs b/Wino.Core.Tests/Synchronizers/ImapClientPoolTests.cs index 5e92910c..3d241fbb 100644 --- a/Wino.Core.Tests/Synchronizers/ImapClientPoolTests.cs +++ b/Wino.Core.Tests/Synchronizers/ImapClientPoolTests.cs @@ -1,8 +1,4 @@ using FluentAssertions; -using Wino.Core.Domain.Entities.Shared; -using Wino.Core.Domain.Enums; -using Wino.Core.Domain.Exceptions; -using Wino.Core.Domain.Models.Connectivity; using Wino.Core.Integration; using Xunit; @@ -34,50 +30,4 @@ public class ImapClientPoolTests { ImapClientPool.CalculateTargetMinimumConnections(maxConnections: 5, useConservativeConnections: false).Should().Be(2); } - - [Fact] - public async Task RentAsync_ShouldThrowImapClientPoolException_WhenAcquireTimesOut() - { - var serverInformation = new CustomServerInformation - { - Id = Guid.NewGuid(), - IncomingServer = "127.0.0.1", - IncomingServerPort = "1", - IncomingServerUsername = "user", - IncomingServerPassword = "password", - IncomingServerSocketOption = ImapConnectionSecurity.None, - IncomingAuthenticationMethod = ImapAuthenticationMethod.Auto, - MaxConcurrentClients = 2 - }; - - using var pool = new ImapClientPool(ImapClientPoolOptions.CreateTestPool(serverInformation, protocolLog: null)); - - var act = async () => await pool.RentAsync(TimeSpan.FromMilliseconds(400)); - var exception = await act.Should().ThrowAsync(); - - exception.Which.CustomServerInformation.Should().NotBeNull(); - } - - [Fact] - public async Task InitializeAsync_ShouldBeSafe_WhenCalledConcurrently() - { - var serverInformation = new CustomServerInformation - { - Id = Guid.NewGuid(), - IncomingServer = "127.0.0.1", - IncomingServerPort = "1", - IncomingServerUsername = "user", - IncomingServerPassword = "password", - IncomingServerSocketOption = ImapConnectionSecurity.None, - IncomingAuthenticationMethod = ImapAuthenticationMethod.Auto, - MaxConcurrentClients = 2 - }; - - using var pool = new ImapClientPool(ImapClientPoolOptions.CreateTestPool(serverInformation, protocolLog: null)); - - var init1 = pool.InitializeAsync(); - var init2 = pool.InitializeAsync(); - - await Task.WhenAll(init1, init2); - } } diff --git a/Wino.Core.Tests/Synchronizers/ImapSynchronizerCalDavConfigurationTests.cs b/Wino.Core.Tests/Synchronizers/ImapSynchronizerCalDavConfigurationTests.cs new file mode 100644 index 00000000..3d60a841 --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/ImapSynchronizerCalDavConfigurationTests.cs @@ -0,0 +1,172 @@ +using System.Reflection; +using FluentAssertions; +using Moq; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Integration.Processors; +using Wino.Core.Synchronizers.ImapSync; +using Wino.Core.Synchronizers.Mail; +using Xunit; + +namespace Wino.Core.Tests.Synchronizers; + +public class ImapSynchronizerCalDavConfigurationTests +{ + [Fact] + public async Task ResolveCalDavServiceUriAsync_UsesExplicitConfigurationBeforeAutoDiscovery() + { + var tempDirectory = CreateTempDirectory(); + var autoDiscovery = new Mock(MockBehavior.Strict); + + var serverInformation = CreateServerInformation(); + serverInformation.CalDavServiceUrl = "https://caldav.explicit.example.com/"; + + var synchronizer = CreateSynchronizer(tempDirectory, serverInformation, autoDiscovery.Object); + + try + { + var resolvedUri = await InvokePrivateAsync(synchronizer, "ResolveCalDavServiceUriAsync", CancellationToken.None); + + resolvedUri.Should().Be(new Uri("https://caldav.explicit.example.com/")); + autoDiscovery.Verify(a => a.DiscoverCalDavServiceUriAsync(It.IsAny(), It.IsAny()), Times.Never); + } + finally + { + await synchronizer.KillSynchronizerAsync(); + DeleteDirectory(tempDirectory); + } + } + + [Fact] + public async Task ResolveCalDavPassword_PrefersExplicitCalDavPassword() + { + var tempDirectory = CreateTempDirectory(); + + var serverInformation = CreateServerInformation(); + serverInformation.IncomingServerPassword = "incoming-password"; + serverInformation.OutgoingServerPassword = "outgoing-password"; + serverInformation.CalDavPassword = "caldav-password"; + + var synchronizer = CreateSynchronizer(tempDirectory, serverInformation); + + try + { + var password = InvokePrivate(synchronizer, "ResolveCalDavPassword"); + + password.Should().Be("caldav-password"); + } + finally + { + await synchronizer.KillSynchronizerAsync(); + DeleteDirectory(tempDirectory); + } + } + + [Fact] + public async Task ResolveCalDavUsername_PrefersExplicitCalDavUsername() + { + var tempDirectory = CreateTempDirectory(); + + var serverInformation = CreateServerInformation(); + serverInformation.Address = "fallback@example.com"; + serverInformation.CalDavUsername = "calendar-user@example.com"; + + var synchronizer = CreateSynchronizer(tempDirectory, serverInformation); + + try + { + var username = InvokePrivate(synchronizer, "ResolveCalDavUsername"); + + username.Should().Be("calendar-user@example.com"); + } + finally + { + await synchronizer.KillSynchronizerAsync(); + DeleteDirectory(tempDirectory); + } + } + + private static ImapSynchronizer CreateSynchronizer(string appDataFolder, + CustomServerInformation serverInformation, + IAutoDiscoveryService autoDiscoveryService = null) + { + var account = new MailAccount + { + Id = Guid.NewGuid(), + Name = "IMAP Test", + Address = "test@example.com", + ProviderType = MailProviderType.IMAP4, + IsCalendarAccessGranted = true, + ServerInformation = serverInformation + }; + + var applicationConfiguration = new Mock(); + 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(), + Mock.Of(), + Mock.Of()); + + return new ImapSynchronizer( + account, + Mock.Of(), + applicationConfiguration.Object, + unifiedSynchronizer, + Mock.Of(), + Mock.Of(), + autoDiscoveryService ?? Mock.Of()); + } + + private static CustomServerInformation CreateServerInformation() + => new() + { + 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 + }; + + private static string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "wino-imap-caldav-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 T InvokePrivate(object instance, string methodName) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException($"Method '{methodName}' not found."); + + return (T)method.Invoke(instance, null)!; + } + + 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)!; + return await task.ConfigureAwait(false); + } +} diff --git a/Wino.Core.Tests/Synchronizers/ImapSynchronizerIdleTests.cs b/Wino.Core.Tests/Synchronizers/ImapSynchronizerIdleTests.cs index 21b7ea98..5158e560 100644 --- a/Wino.Core.Tests/Synchronizers/ImapSynchronizerIdleTests.cs +++ b/Wino.Core.Tests/Synchronizers/ImapSynchronizerIdleTests.cs @@ -74,6 +74,8 @@ public class ImapSynchronizerIdleTests Mock.Of(), applicationConfiguration.Object, unifiedSynchronizer, - Mock.Of()); + Mock.Of(), + Mock.Of(), + Mock.Of()); } } diff --git a/Wino.Core/Integration/ImapClientPool.cs b/Wino.Core/Integration/ImapClientPool.cs index 2694b4aa..b6cce324 100644 --- a/Wino.Core/Integration/ImapClientPool.cs +++ b/Wino.Core/Integration/ImapClientPool.cs @@ -54,6 +54,7 @@ public class ImapClientPool : IDisposable private readonly CancellationTokenSource _maintenanceCts = new(); private readonly SemaphoreSlim _initializeSemaphore = new(1, 1); private readonly object _idleClientLock = new(); + private readonly object _initialWarmupLock = new(); private readonly ImapServerQuirkProfile _quirks; private readonly ImapImplementation _implementation; private readonly int _maxConnections; @@ -64,6 +65,7 @@ public class ImapClientPool : IDisposable private bool _disposedValue; private bool _initialized; private Task _maintenanceTask; + private Task _initialWarmupTask = Task.CompletedTask; public bool ThrowOnSSLHandshakeCallback { get; set; } public ImapClientPoolOptions ImapClientPoolOptions { get; } @@ -112,30 +114,20 @@ public class ImapClientPool : IDisposable _logger.Information("Initializing IMAP client pool with {MinimumConnections} minimum active connections (max: {MaxConnections})", _targetMinimumConnections, _maxConnections); - for (int i = 0; i < _targetMinimumConnections; i++) + // Fast-path startup: create one client eagerly so first RentAsync() is not blocked by full warm-up. + var initialClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false); + if (initialClient == null) { - cancellationToken.ThrowIfCancellationRequested(); - - var client = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false); - if (client != null) - { - _clientStates[client] = ImapClientState.Available; - await _availableClients.Writer.WriteAsync(client, cancellationToken).ConfigureAwait(false); - } + throw CreatePoolException("Failed to create initial IMAP connection for the pool."); } - if (CanCreateAdditionalConnection()) - { - _dedicatedIdleClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false); - if (_dedicatedIdleClient != null) - { - _clientStates[_dedicatedIdleClient] = ImapClientState.Idle; - } - } + _clientStates[initialClient] = ImapClientState.Available; + await _availableClients.Writer.WriteAsync(initialClient, cancellationToken).ConfigureAwait(false); _maintenanceTask = Task.Run(() => MaintenanceLoopAsync(_maintenanceCts.Token), _maintenanceCts.Token); - _initialized = true; + + ScheduleInitialWarmup(); _logger.Information("IMAP client pool initialized. Health: {Health}", Health.Summary); } catch (Exception ex) @@ -152,7 +144,21 @@ public class ImapClientPool : IDisposable /// /// Pre-warms the pool (legacy compatibility method). /// - public Task PreWarmPoolAsync() => InitializeAsync(CancellationToken.None); + public async Task PreWarmPoolAsync() + { + await InitializeAsync(CancellationToken.None).ConfigureAwait(false); + + Task warmupTask; + lock (_initialWarmupLock) + { + warmupTask = _initialWarmupTask; + } + + if (warmupTask != null) + { + await warmupTask.ConfigureAwait(false); + } + } /// /// Rents a client from the pool with the default timeout. @@ -440,6 +446,63 @@ public class ImapClientPool : IDisposable } } + private void ScheduleInitialWarmup() + { + lock (_initialWarmupLock) + { + if (_initialWarmupTask != null && !_initialWarmupTask.IsCompleted) + return; + + _initialWarmupTask = Task.Run(() => EnsureWarmBaselineAsync(_maintenanceCts.Token), _maintenanceCts.Token); + } + } + + private async Task EnsureWarmBaselineAsync(CancellationToken cancellationToken) + { + try + { + await EnsureMinimumConnectionsAsync(cancellationToken).ConfigureAwait(false); + + lock (_idleClientLock) + { + if (_dedicatedIdleClient != null && _dedicatedIdleClient.IsConnected) + return; + } + + if (!CanCreateAdditionalConnection()) + return; + + var idleCandidate = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false); + if (idleCandidate == null) + return; + + bool assignedAsIdle = false; + lock (_idleClientLock) + { + if (_dedicatedIdleClient == null || !_dedicatedIdleClient.IsConnected) + { + _dedicatedIdleClient = idleCandidate; + _clientStates[idleCandidate] = ImapClientState.Idle; + assignedAsIdle = true; + } + } + + if (!assignedAsIdle) + { + _clientStates[idleCandidate] = ImapClientState.Available; + _availableClients.Writer.TryWrite(idleCandidate); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Pool is shutting down. + } + catch (Exception ex) + { + _logger.Warning(ex, "Initial IMAP pool warm-up failed. Pool will continue with maintenance recovery."); + } + } + private Task CleanupFailedConnectionsAsync() { foreach (var kvp in _clientStates) diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index d8f0d9d3..f231e022 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -9,6 +9,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; +using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Synchronization; using Wino.Services; @@ -117,6 +118,11 @@ public interface IImapChangeProcessor : IDefaultChangeProcessor /// Folder ID. /// Number of recent mails to return. Task> GetRecentMailIdsForFolderAsync(Guid folderId, int count); + + Task ManageCalendarEventAsync(CalDavCalendarEvent calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount); + Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent); + Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId); + Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId); } public class DefaultChangeProcessor(IDatabaseService databaseService, @@ -196,10 +202,10 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, public Task> GetAccountCalendarsAsync(Guid accountId) => CalendarService.GetAccountCalendarsAsync(accountId); - public Task DeleteCalendarItemAsync(Guid calendarItemId) + public virtual Task DeleteCalendarItemAsync(Guid calendarItemId) => CalendarService.DeleteCalendarItemAsync(calendarItemId); - public Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId) + public virtual Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId) => CalendarService.DeleteCalendarItemAsync(calendarRemoteEventId, calendarId); public Task GetCalendarItemAsync(Guid calendarId, string remoteEventId) diff --git a/Wino.Core/Integration/Processors/ImapChangeProcessor.cs b/Wino.Core/Integration/Processors/ImapChangeProcessor.cs index 72b74f04..74439a9b 100644 --- a/Wino.Core/Integration/Processors/ImapChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/ImapChangeProcessor.cs @@ -1,24 +1,158 @@ -using System; +using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Calendar; using Wino.Services; namespace Wino.Core.Integration.Processors; public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor { + private readonly ICalendarIcsFileService _calendarIcsFileService; + public ImapChangeProcessor(IDatabaseService databaseService, IFolderService folderService, IMailService mailService, IAccountService accountService, ICalendarService calendarService, - IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService) + IMimeFileService mimeFileService, + ICalendarIcsFileService calendarIcsFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService) { + _calendarIcsFileService = calendarIcsFileService; } public Task> GetKnownUidsForFolderAsync(Guid folderId) => FolderService.GetKnownUidsForFolderAsync(folderId); public Task> GetRecentMailIdsForFolderAsync(Guid folderId, int count) => MailService.GetRecentMailIdsForFolderAsync(folderId, count); + + public async Task ManageCalendarEventAsync(CalDavCalendarEvent calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount) + { + if (calendarEvent == null || assignedCalendar == null) + return; + + var existingItem = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.RemoteEventId).ConfigureAwait(false); + var isNewItem = existingItem == null; + var savingItemId = existingItem?.Id ?? Guid.NewGuid(); + var savingItem = existingItem ?? new CalendarItem { Id = savingItemId }; + + var start = calendarEvent.Start.UtcDateTime; + var end = calendarEvent.End.UtcDateTime; + + if (end <= start) + end = start.AddHours(1); + + savingItem.RemoteEventId = calendarEvent.RemoteEventId; + savingItem.CalendarId = assignedCalendar.Id; + savingItem.StartDate = start; + savingItem.DurationInSeconds = (end - start).TotalSeconds; + savingItem.StartTimeZone = calendarEvent.StartTimeZone; + savingItem.EndTimeZone = calendarEvent.EndTimeZone; + savingItem.Title = calendarEvent.Title; + savingItem.Description = calendarEvent.Description; + savingItem.Location = calendarEvent.Location; + savingItem.Recurrence = calendarEvent.Recurrence; + savingItem.Status = calendarEvent.Status; + savingItem.Visibility = calendarEvent.Visibility; + savingItem.ShowAs = calendarEvent.ShowAs; + savingItem.IsHidden = calendarEvent.IsHidden; + savingItem.HtmlLink = string.Empty; + savingItem.IsLocked = false; + savingItem.OrganizerDisplayName = !string.IsNullOrWhiteSpace(calendarEvent.OrganizerDisplayName) + ? calendarEvent.OrganizerDisplayName + : organizerAccount?.SenderName ?? string.Empty; + savingItem.OrganizerEmail = !string.IsNullOrWhiteSpace(calendarEvent.OrganizerEmail) + ? calendarEvent.OrganizerEmail + : organizerAccount?.Address ?? string.Empty; + savingItem.AssignedCalendar = assignedCalendar; + + if (savingItem.CreatedAt == default) + savingItem.CreatedAt = DateTimeOffset.UtcNow; + + savingItem.UpdatedAt = DateTimeOffset.UtcNow; + + if (!string.IsNullOrWhiteSpace(calendarEvent.SeriesMasterRemoteEventId)) + { + var parentEvent = await CalendarService + .GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.SeriesMasterRemoteEventId) + .ConfigureAwait(false); + + if (parentEvent != null) + { + savingItem.RecurringCalendarItemId = parentEvent.Id; + } + } + else + { + savingItem.RecurringCalendarItemId = null; + } + + var attendees = calendarEvent.Attendees? + .Where(a => !string.IsNullOrWhiteSpace(a.Email)) + .Select(a => new CalendarEventAttendee + { + Id = Guid.NewGuid(), + CalendarItemId = savingItemId, + Name = a.Name, + Email = a.Email, + AttendenceStatus = a.AttendenceStatus, + IsOrganizer = a.IsOrganizer, + IsOptionalAttendee = a.IsOptionalAttendee + }) + .ToList(); + + var reminders = calendarEvent.Reminders? + .Where(r => r.DurationInSeconds > 0) + .Select(r => new Reminder + { + Id = Guid.NewGuid(), + CalendarItemId = savingItemId, + DurationInSeconds = r.DurationInSeconds, + ReminderType = r.ReminderType + }) + .ToList(); + + if (isNewItem) + { + await CalendarService.CreateNewCalendarItemAsync(savingItem, attendees).ConfigureAwait(false); + } + else + { + await CalendarService.UpdateCalendarItemAsync(savingItem, attendees).ConfigureAwait(false); + } + + await CalendarService.SaveRemindersAsync(savingItemId, reminders).ConfigureAwait(false); + } + + public Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent) + => _calendarIcsFileService.SaveCalendarItemIcsAsync(accountId, calendarId, calendarItemId, remoteEventId, remoteResourceHref, eTag, icsContent); + + public Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId) + => _calendarIcsFileService.DeleteCalendarItemIcsAsync(accountId, calendarItemId); + + public Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId) + => _calendarIcsFileService.DeleteCalendarIcsForCalendarAsync(accountId, calendarId); + + public override async Task DeleteCalendarItemAsync(Guid calendarItemId) + { + var item = await CalendarService.GetCalendarItemAsync(calendarItemId).ConfigureAwait(false); + if (item == null) + return; + + await _calendarIcsFileService.DeleteCalendarItemIcsAsync(item.AssignedCalendar?.AccountId ?? Guid.Empty, calendarItemId).ConfigureAwait(false); + await base.DeleteCalendarItemAsync(calendarItemId).ConfigureAwait(false); + } + + public override async Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId) + { + var item = await CalendarService.GetCalendarItemAsync(calendarId, calendarRemoteEventId).ConfigureAwait(false); + if (item == null) + return; + + await DeleteCalendarItemAsync(item.Id).ConfigureAwait(false); + } } diff --git a/Wino.Core/Services/AutoDiscoveryService.cs b/Wino.Core/Services/AutoDiscoveryService.cs index 05cf06d0..10d21137 100644 --- a/Wino.Core/Services/AutoDiscoveryService.cs +++ b/Wino.Core/Services/AutoDiscoveryService.cs @@ -1,7 +1,12 @@ -using System; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; using System.Net.Http; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; +using System.Xml.Linq; using Serilog; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models; @@ -10,47 +15,682 @@ using Wino.Core.Domain.Models.AutoDiscovery; namespace Wino.Core.Services; /// -/// We have 2 methods to do auto discovery. -/// 1. Use https://emailsettings.firetrust.com/settings?q={address} API -/// 2. TODO: Thunderbird auto discovery file. +/// Mail and CalDAV endpoint discovery with Thunderbird-style methods and fallbacks. /// public class AutoDiscoveryService : IAutoDiscoveryService { - private const string FiretrustURL = " https://emailsettings.firetrust.com/settings?q="; + private const string ThunderbirdIspdbUrl = "https://autoconfig.thunderbird.net/v1.1/"; + private const string FiretrustUrl = "https://emailsettings.firetrust.com/settings?q="; + private const string GoogleDnsResolveUrl = "https://dns.google/resolve"; - // TODO: Try Thunderbird Auto Discovery as second approach. + private static readonly ILogger Logger = Log.ForContext(); + private static readonly StringComparer IgnoreCase = StringComparer.OrdinalIgnoreCase; + private static readonly HttpMethod OptionsMethod = new("OPTIONS"); - public Task GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings) - => GetSettingsFromFiretrustAsync(autoDiscoveryMinimalSettings.Email); + private readonly HttpClient _httpClient; + private readonly Dictionary _calDavUriCache = new(IgnoreCase); + private readonly object _calDavCacheLock = new(); - private static async Task GetSettingsFromFiretrustAsync(string mailAddress) + public AutoDiscoveryService(HttpClient httpClient = null) { - using var client = new HttpClient(); - var response = await client.GetAsync($"{FiretrustURL}{mailAddress}"); - - if (response.IsSuccessStatusCode) - return await DeserializeFiretrustResponse(response); - else + _httpClient = httpClient ?? new HttpClient { - Log.Warning($"Firetrust AutoDiscovery failed. ({response.StatusCode})"); - - return null; - } + Timeout = TimeSpan.FromSeconds(15) + }; } - private static async Task DeserializeFiretrustResponse(HttpResponseMessage response) + public async Task GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings) { - try - { - var content = await response.Content.ReadAsStringAsync(); + if (autoDiscoveryMinimalSettings == null || string.IsNullOrWhiteSpace(autoDiscoveryMinimalSettings.Email)) + return null; - return JsonSerializer.Deserialize(content, DomainModelsJsonContext.Default.AutoDiscoverySettings); - } - catch (Exception ex) + if (!TryGetEmailParts(autoDiscoveryMinimalSettings.Email, out var localPart, out var domain)) + return null; + + var cancellationToken = CancellationToken.None; + + var settings = await TryGetThunderbirdSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, localPart, cancellationToken).ConfigureAwait(false) + ?? await TryGetIspdbSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, localPart, cancellationToken).ConfigureAwait(false) + ?? await TryGetMxBasedSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, localPart, cancellationToken).ConfigureAwait(false) + ?? await TryGetSrvBasedSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, cancellationToken).ConfigureAwait(false) + ?? await TryGetGuessedHostSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, cancellationToken).ConfigureAwait(false) + ?? await GetSettingsFromFiretrustAsync(autoDiscoveryMinimalSettings.Email, cancellationToken).ConfigureAwait(false); + + if (settings != null && string.IsNullOrWhiteSpace(settings.Domain)) { - Log.Error(ex, "Failed to deserialize Firetrust response."); + settings.Domain = domain; + } + + return settings; + } + + public async Task DiscoverCalDavServiceUriAsync(string mailAddress, CancellationToken cancellationToken = default) + { + if (!TryGetEmailParts(mailAddress, out _, out var domain)) + return null; + + lock (_calDavCacheLock) + { + if (_calDavUriCache.TryGetValue(domain, out var cachedUri)) + return cachedUri; + } + + var knownProviderUri = TryGetKnownProviderCalDavUri(domain); + if (knownProviderUri != null) + { + CacheCalDavUri(domain, knownProviderUri); + return knownProviderUri; + } + + foreach (var candidate in GetCalDavCandidates(domain)) + { + var resolved = await TryResolveCalDavEndpointAsync(candidate, cancellationToken).ConfigureAwait(false); + if (resolved == null) + continue; + + CacheCalDavUri(domain, resolved); + return resolved; } return null; } + + private async Task TryGetThunderbirdSettingsAsync( + string lookupDomain, + string email, + string localPart, + CancellationToken cancellationToken) + { + foreach (var endpoint in BuildThunderbirdEndpoints(lookupDomain, email)) + { + var settings = await TryGetSettingsFromXmlEndpointAsync(endpoint, email, localPart, lookupDomain, cancellationToken).ConfigureAwait(false); + if (settings != null) + return settings; + } + + return null; + } + + private async Task TryGetIspdbSettingsAsync( + string lookupDomain, + string email, + string localPart, + CancellationToken cancellationToken) + { + var endpoint = $"{ThunderbirdIspdbUrl}{lookupDomain}?emailaddress={Uri.EscapeDataString(email)}"; + return await TryGetSettingsFromXmlEndpointAsync(endpoint, email, localPart, lookupDomain, cancellationToken).ConfigureAwait(false); + } + + private async Task TryGetMxBasedSettingsAsync( + string domain, + string email, + string localPart, + CancellationToken cancellationToken) + { + var mxDomains = await GetMxSearchDomainsAsync(domain, cancellationToken).ConfigureAwait(false); + + foreach (var mxDomain in mxDomains) + { + if (IgnoreCase.Equals(mxDomain, domain)) + continue; + + var settings = await TryGetThunderbirdSettingsAsync(mxDomain, email, localPart, cancellationToken).ConfigureAwait(false) + ?? await TryGetIspdbSettingsAsync(mxDomain, email, localPart, cancellationToken).ConfigureAwait(false); + + if (settings != null) + return settings; + } + + return null; + } + + private async Task TryGetSrvBasedSettingsAsync( + string domain, + string email, + CancellationToken cancellationToken) + { + var incoming = await TryResolveSrvRecordAsync($"_imaps._tcp.{domain}", "IMAP", "SSL", cancellationToken).ConfigureAwait(false) + ?? await TryResolveSrvRecordAsync($"_imap._tcp.{domain}", "IMAP", "STARTTLS", cancellationToken).ConfigureAwait(false); + + var outgoing = await TryResolveSrvRecordAsync($"_submissions._tcp.{domain}", "SMTP", "SSL", cancellationToken).ConfigureAwait(false) + ?? await TryResolveSrvRecordAsync($"_submission._tcp.{domain}", "SMTP", "STARTTLS", cancellationToken).ConfigureAwait(false) + ?? await TryResolveSrvRecordAsync($"_smtp._tcp.{domain}", "SMTP", "STARTTLS", cancellationToken).ConfigureAwait(false); + + if (incoming == null || outgoing == null) + return null; + + incoming.Username = email; + outgoing.Username = email; + + return new AutoDiscoverySettings + { + Domain = domain, + Settings = [incoming, outgoing] + }; + } + + private async Task TryGetGuessedHostSettingsAsync( + string domain, + string email, + CancellationToken cancellationToken) + { + var imapHost = await GetFirstResolvableHostAsync( + [$"imap.{domain}", $"mail.{domain}", domain], + cancellationToken).ConfigureAwait(false); + + var smtpHost = await GetFirstResolvableHostAsync( + [$"smtp.{domain}", $"mail.{domain}", domain], + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(imapHost) || string.IsNullOrWhiteSpace(smtpHost)) + return null; + + return new AutoDiscoverySettings + { + Domain = domain, + Settings = + [ + new AutoDiscoveryProviderSetting + { + Protocol = "IMAP", + Address = imapHost, + Port = 993, + Secure = "SSL", + Username = email + }, + new AutoDiscoveryProviderSetting + { + Protocol = "SMTP", + Address = smtpHost, + Port = 587, + Secure = "STARTTLS", + Username = email + } + ] + }; + } + + private async Task TryGetSettingsFromXmlEndpointAsync( + string endpoint, + string email, + string localPart, + string domain, + CancellationToken cancellationToken) + { + try + { + using var response = await _httpClient.GetAsync(endpoint, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return null; + + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return ParseThunderbirdSettings(content, email, localPart, domain); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.Debug(ex, "Failed to read autodiscovery XML endpoint {Endpoint}", endpoint); + return null; + } + } + + private static AutoDiscoverySettings ParseThunderbirdSettings(string xmlContent, string email, string localPart, string domain) + { + if (string.IsNullOrWhiteSpace(xmlContent)) + return null; + + try + { + var document = XDocument.Parse(xmlContent); + + var incomingServers = document + .Descendants() + .Where(e => e.Name.LocalName == "incomingServer") + .Where(e => string.Equals((string)e.Attribute("type"), "imap", StringComparison.OrdinalIgnoreCase)) + .Select(e => ParseThunderbirdServer(e, "IMAP", email, localPart, domain)) + .Where(e => e != null) + .ToList(); + + var outgoingServers = document + .Descendants() + .Where(e => e.Name.LocalName == "outgoingServer") + .Where(e => string.Equals((string)e.Attribute("type"), "smtp", StringComparison.OrdinalIgnoreCase)) + .Select(e => ParseThunderbirdServer(e, "SMTP", email, localPart, domain)) + .Where(e => e != null) + .ToList(); + + var bestIncoming = SelectBestServerSetting(incomingServers); + var bestOutgoing = SelectBestServerSetting(outgoingServers); + + if (bestIncoming == null || bestOutgoing == null) + return null; + + return new AutoDiscoverySettings + { + Domain = domain, + Settings = [bestIncoming, bestOutgoing] + }; + } + catch (Exception ex) + { + Logger.Debug(ex, "Failed to parse Thunderbird autodiscovery XML."); + return null; + } + } + + private static AutoDiscoveryProviderSetting ParseThunderbirdServer( + XElement serverElement, + string protocol, + string email, + string localPart, + string domain) + { + var address = ResolveTemplate(GetElementValue(serverElement, "hostname"), email, localPart, domain); + var username = ResolveTemplate(GetElementValue(serverElement, "username"), email, localPart, domain); + var socketType = ResolveTemplate(GetElementValue(serverElement, "socketType"), email, localPart, domain); + + if (string.IsNullOrWhiteSpace(address)) + return null; + + if (!int.TryParse(GetElementValue(serverElement, "port"), out var port)) + return null; + + return new AutoDiscoveryProviderSetting + { + Protocol = protocol, + Address = address.Trim(), + Port = port, + Secure = socketType?.Trim() ?? string.Empty, + Username = string.IsNullOrWhiteSpace(username) ? email : username.Trim() + }; + } + + private static AutoDiscoveryProviderSetting SelectBestServerSetting(IReadOnlyCollection settings) + { + if (settings == null || settings.Count == 0) + return null; + + return settings + .OrderByDescending(GetSecurityScore) + .ThenBy(s => s.Port) + .FirstOrDefault(); + } + + private static int GetSecurityScore(AutoDiscoveryProviderSetting setting) + { + if (setting == null) + return 0; + + var secureValue = setting.Secure ?? string.Empty; + + if (secureValue.Contains("SSL", StringComparison.OrdinalIgnoreCase) || + secureValue.Contains("TLS", StringComparison.OrdinalIgnoreCase)) + { + return 3; + } + + if (secureValue.Contains("STARTTLS", StringComparison.OrdinalIgnoreCase)) + return 2; + + return 1; + } + + private static string GetElementValue(XElement element, string localName) + => element.Elements().FirstOrDefault(e => e.Name.LocalName == localName)?.Value; + + private static string ResolveTemplate(string value, string email, string localPart, string domain) + { + if (string.IsNullOrWhiteSpace(value)) + return value; + + return value + .Replace("%EMAILADDRESS%", email, StringComparison.OrdinalIgnoreCase) + .Replace("%EMAILLOCALPART%", localPart, StringComparison.OrdinalIgnoreCase) + .Replace("%EMAILDOMAIN%", domain, StringComparison.OrdinalIgnoreCase); + } + + private static IEnumerable BuildThunderbirdEndpoints(string domain, string email) + { + var escapedEmail = Uri.EscapeDataString(email); + yield return $"https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={escapedEmail}"; + yield return $"https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={escapedEmail}"; + } + + private async Task GetSettingsFromFiretrustAsync(string mailAddress, CancellationToken cancellationToken) + { + try + { + using var response = await _httpClient.GetAsync($"{FiretrustUrl}{Uri.EscapeDataString(mailAddress)}", cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + Logger.Warning("Firetrust autodiscovery failed with status {StatusCode}", response.StatusCode); + return null; + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(content, DomainModelsJsonContext.Default.AutoDiscoverySettings); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to deserialize Firetrust autodiscovery response."); + return null; + } + } + + private async Task TryResolveSrvRecordAsync( + string queryName, + string protocol, + string secureHint, + CancellationToken cancellationToken) + { + var records = await QueryDnsAsync(queryName, "SRV", cancellationToken).ConfigureAwait(false); + var srvRecord = records + .Select(ParseSrvRecord) + .Where(r => r != null) + .OrderBy(r => r.Priority) + .ThenBy(r => r.Weight) + .FirstOrDefault(); + + if (srvRecord == null) + return null; + + return new AutoDiscoveryProviderSetting + { + Protocol = protocol, + Address = srvRecord.Target, + Port = srvRecord.Port, + Secure = secureHint + }; + } + + private async Task> GetMxSearchDomainsAsync(string domain, CancellationToken cancellationToken) + { + var results = new List { domain }; + var records = await QueryDnsAsync(domain, "MX", cancellationToken).ConfigureAwait(false); + + var hosts = records + .Select(ParseMxRecord) + .Where(r => r != null) + .OrderBy(r => r.Preference) + .Select(r => r.Target) + .Distinct(IgnoreCase) + .ToList(); + + foreach (var host in hosts) + { + foreach (var candidateDomain in BuildDomainCandidatesFromHost(host)) + { + if (!results.Contains(candidateDomain, IgnoreCase)) + { + results.Add(candidateDomain); + } + } + } + + return results; + } + + private async Task GetFirstResolvableHostAsync(IEnumerable hostCandidates, CancellationToken cancellationToken) + { + foreach (var host in hostCandidates.Where(h => !string.IsNullOrWhiteSpace(h)).Distinct(IgnoreCase)) + { + if (await HasAnyDnsAddressRecordAsync(host, cancellationToken).ConfigureAwait(false)) + return host; + } + + return null; + } + + private async Task HasAnyDnsAddressRecordAsync(string host, CancellationToken cancellationToken) + { + var aRecords = await QueryDnsAsync(host, "A", cancellationToken).ConfigureAwait(false); + if (aRecords.Count > 0) + return true; + + var aaaaRecords = await QueryDnsAsync(host, "AAAA", cancellationToken).ConfigureAwait(false); + return aaaaRecords.Count > 0; + } + + private async Task> QueryDnsAsync(string queryName, string queryType, CancellationToken cancellationToken) + { + try + { + var url = $"{GoogleDnsResolveUrl}?name={Uri.EscapeDataString(queryName)}&type={Uri.EscapeDataString(queryType)}"; + using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return Array.Empty(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (!document.RootElement.TryGetProperty("Answer", out var answerArray) || + answerArray.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + var values = new List(); + + foreach (var answer in answerArray.EnumerateArray()) + { + if (answer.TryGetProperty("data", out var dataNode) && dataNode.ValueKind == JsonValueKind.String) + { + var data = dataNode.GetString(); + if (!string.IsNullOrWhiteSpace(data)) + values.Add(data); + } + } + + return values; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.Debug(ex, "DNS-over-HTTPS query failed for {QueryName} ({Type})", queryName, queryType); + return Array.Empty(); + } + } + + private async Task TryResolveCalDavEndpointAsync(Uri candidate, CancellationToken cancellationToken) + { + var getResult = await ProbeCalDavEndpointAsync(candidate, HttpMethod.Get, cancellationToken).ConfigureAwait(false); + if (getResult != null) + return getResult; + + return await ProbeCalDavEndpointAsync(candidate, OptionsMethod, cancellationToken).ConfigureAwait(false); + } + + private async Task ProbeCalDavEndpointAsync(Uri uri, HttpMethod method, CancellationToken cancellationToken) + { + try + { + using var request = new HttpRequestMessage(method, uri); + using var response = await _httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (TryResolveRedirectTarget(uri, response, out var redirectTarget)) + return redirectTarget; + + if (!IsPossibleCalDavEndpoint(response)) + return null; + + return response.RequestMessage?.RequestUri ?? uri; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.Debug(ex, "CalDAV probe failed for {Uri} with method {Method}", uri, method); + return null; + } + } + + private static bool IsPossibleCalDavEndpoint(HttpResponseMessage response) + { + if (response == null) + return false; + + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.MultiStatus) + return true; + + var hasDavHeader = response.Headers.Contains("DAV"); + var hasDavMethod = response.Headers.TryGetValues("Allow", out var allowValues) + && allowValues.Any(value => + value.Contains("PROPFIND", StringComparison.OrdinalIgnoreCase) || + value.Contains("REPORT", StringComparison.OrdinalIgnoreCase)); + + if (response.StatusCode == HttpStatusCode.MethodNotAllowed) + return hasDavHeader || hasDavMethod; + + return response.IsSuccessStatusCode && (hasDavHeader || hasDavMethod); + } + + private static bool TryResolveRedirectTarget(Uri baseUri, HttpResponseMessage response, out Uri resolvedUri) + { + resolvedUri = null; + + if (response == null || !IsRedirectStatusCode(response.StatusCode)) + return false; + + if (response.Headers.Location == null) + return false; + + resolvedUri = response.Headers.Location.IsAbsoluteUri + ? response.Headers.Location + : new Uri(baseUri, response.Headers.Location); + + return true; + } + + private static bool IsRedirectStatusCode(HttpStatusCode statusCode) + => statusCode == HttpStatusCode.MovedPermanently + || statusCode == HttpStatusCode.Found + || statusCode == HttpStatusCode.RedirectMethod + || statusCode == HttpStatusCode.TemporaryRedirect + || (int)statusCode == 308; + + private static Uri TryGetKnownProviderCalDavUri(string domain) + { + if (domain.EndsWith("icloud.com", StringComparison.OrdinalIgnoreCase) || + domain.EndsWith("me.com", StringComparison.OrdinalIgnoreCase) || + domain.EndsWith("mac.com", StringComparison.OrdinalIgnoreCase)) + { + return new Uri("https://caldav.icloud.com/"); + } + + if (domain.Contains("yahoo.", StringComparison.OrdinalIgnoreCase) || + domain.EndsWith("aol.com", StringComparison.OrdinalIgnoreCase)) + { + return new Uri("https://caldav.calendar.yahoo.com/"); + } + + return null; + } + + private static IEnumerable GetCalDavCandidates(string domain) + { + foreach (var candidateDomain in BuildDomainCandidatesFromHost(domain)) + { + yield return new Uri($"https://{candidateDomain}/.well-known/caldav"); + yield return new Uri($"https://caldav.{candidateDomain}/"); + } + } + + private static IEnumerable BuildDomainCandidatesFromHost(string hostOrDomain) + { + if (string.IsNullOrWhiteSpace(hostOrDomain)) + yield break; + + var normalized = hostOrDomain.Trim().TrimEnd('.'); + if (string.IsNullOrWhiteSpace(normalized)) + yield break; + + yield return normalized; + + var segments = normalized.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length > 2) + { + yield return string.Join('.', segments.Skip(1)); + } + } + + private static bool TryGetEmailParts(string email, out string localPart, out string domain) + { + localPart = null; + domain = null; + + if (string.IsNullOrWhiteSpace(email)) + return false; + + var separatorIndex = email.IndexOf('@'); + if (separatorIndex <= 0 || separatorIndex >= email.Length - 1) + return false; + + localPart = email[..separatorIndex]; + domain = email[(separatorIndex + 1)..]; + return !string.IsNullOrWhiteSpace(localPart) && !string.IsNullOrWhiteSpace(domain); + } + + private void CacheCalDavUri(string domain, Uri calDavUri) + { + lock (_calDavCacheLock) + { + _calDavUriCache[domain] = calDavUri; + } + } + + private static SrvRecord ParseSrvRecord(string rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + return null; + + var parts = rawValue.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 4) + return null; + + if (!ushort.TryParse(parts[0], out var priority) || + !ushort.TryParse(parts[1], out var weight) || + !int.TryParse(parts[2], out var port)) + { + return null; + } + + var target = parts[3].Trim().TrimEnd('.'); + if (string.IsNullOrWhiteSpace(target)) + return null; + + return new SrvRecord(priority, weight, port, target); + } + + private static MxRecord ParseMxRecord(string rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + return null; + + var parts = rawValue.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 || !ushort.TryParse(parts[0], out var preference)) + return null; + + var target = parts[1].Trim().TrimEnd('.'); + if (string.IsNullOrWhiteSpace(target)) + return null; + + return new MxRecord(preference, target); + } + + private sealed record SrvRecord(ushort Priority, ushort Weight, int Port, string Target); + private sealed record MxRecord(ushort Preference, string Target); } diff --git a/Wino.Core/Services/SynchronizerFactory.cs b/Wino.Core/Services/SynchronizerFactory.cs index ea082264..501dc43c 100644 --- a/Wino.Core/Services/SynchronizerFactory.cs +++ b/Wino.Core/Services/SynchronizerFactory.cs @@ -23,6 +23,8 @@ public class SynchronizerFactory : ISynchronizerFactory private readonly IImapChangeProcessor _imapChangeProcessor; private readonly IAuthenticationProvider _authenticationProvider; private readonly UnifiedImapSynchronizer _unifiedImapSynchronizer; + private readonly ICalDavClient _calDavClient; + private readonly IAutoDiscoveryService _autoDiscoveryService; private readonly List synchronizerCache = new(); @@ -35,7 +37,9 @@ public class SynchronizerFactory : ISynchronizerFactory IOutlookSynchronizerErrorHandlerFactory outlookSynchronizerErrorHandlerFactory, IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory, IImapSynchronizerErrorHandlerFactory imapSynchronizerErrorHandlerFactory, - UnifiedImapSynchronizer unifiedImapSynchronizer) + UnifiedImapSynchronizer unifiedImapSynchronizer, + ICalDavClient calDavClient, + IAutoDiscoveryService autoDiscoveryService) { _outlookChangeProcessor = outlookChangeProcessor; _gmailChangeProcessor = gmailChangeProcessor; @@ -47,6 +51,8 @@ public class SynchronizerFactory : ISynchronizerFactory _gmailSynchronizerErrorHandlerFactory = gmailSynchronizerErrorHandlerFactory; _imapSynchronizerErrorHandlerFactory = imapSynchronizerErrorHandlerFactory; _unifiedImapSynchronizer = unifiedImapSynchronizer; + _calDavClient = calDavClient; + _autoDiscoveryService = autoDiscoveryService; } public async Task GetAccountSynchronizerAsync(Guid accountId) @@ -82,7 +88,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); + return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory, _calDavClient, _autoDiscoveryService); default: break; } diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index a7a824b4..607bfff2 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -10,11 +10,13 @@ using MailKit.Net.Imap; using MailKit.Search; using MimeKit; using Serilog; +using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Connectivity; using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; @@ -26,6 +28,7 @@ using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Folder; using Wino.Core.Requests.Mail; using Wino.Core.Synchronizers.ImapSync; +using Wino.Core.Misc; using Wino.Messaging.Server; using Wino.Messaging.UI; using Wino.Services.Extensions; @@ -58,18 +61,27 @@ public class ImapSynchronizer : WinoSynchronizer !localFolder.FolderName.Equals(remoteFolder.Name, StringComparison.OrdinalIgnoreCase); - protected override Task SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + protected override async Task SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) + { + if (Account.ProviderType != MailProviderType.IMAP4 || !Account.IsCalendarAccessGranted || Account.ServerInformation == null) + return CalendarSynchronizationResult.Empty; + + if (Account.ServerInformation.CalendarSupportMode is ImapCalendarSupportMode.Disabled or ImapCalendarSupportMode.LocalOnly) + return CalendarSynchronizationResult.Empty; + + var calDavServiceUri = await ResolveCalDavServiceUriAsync(cancellationToken).ConfigureAwait(false); + if (calDavServiceUri == null) + { + _logger.Information("Skipping calendar sync for {AccountName}: CalDAV endpoint is not configured.", Account.Name); + return CalendarSynchronizationResult.Empty; + } + + var password = ResolveCalDavPassword(); + if (string.IsNullOrWhiteSpace(password)) + { + _logger.Warning("Skipping calendar sync for {AccountName}: empty credentials.", Account.Name); + return CalendarSynchronizationResult.Empty; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var calDavUsername = ResolveCalDavUsername(); + if (string.IsNullOrWhiteSpace(calDavUsername)) + { + _logger.Warning("Skipping calendar sync for {AccountName}: account email address is empty for CalDAV credentials.", Account.Name); + return CalendarSynchronizationResult.Empty; + } + + var activeConnection = new CalDavConnectionSettings + { + ServiceUri = calDavServiceUri, + Username = calDavUsername, + Password = password + }; + + IReadOnlyList remoteCalendars; + + try + { + remoteCalendars = await _calDavClient + .DiscoverCalendarsAsync(activeConnection, cancellationToken) + .ConfigureAwait(false); + } + catch (UnauthorizedAccessException) + { + _logger.Warning("Skipping calendar sync for {AccountName}: CalDAV authentication failed for username {Username}.", Account.Name, calDavUsername); + return CalendarSynchronizationResult.Empty; + } + + await SynchronizeCalendarMetadataAsync(remoteCalendars).ConfigureAwait(false); + + var localCalendars = await _imapChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false); + var remoteCalendarsById = remoteCalendars.ToDictionary(c => c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase); + + if (options?.Type == CalendarSynchronizationType.SingleCalendar && options.SynchronizationCalendarIds?.Count > 0) + { + localCalendars = localCalendars + .Where(c => options.SynchronizationCalendarIds.Contains(c.Id)) + .ToList(); + } + + localCalendars = localCalendars + .Where(c => c.IsSynchronizationEnabled) + .ToList(); + + var periodStartUtc = DateTimeOffset.UtcNow.AddYears(-1); + var periodEndUtc = DateTimeOffset.UtcNow.AddYears(2); + + foreach (var localCalendar in localCalendars) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!remoteCalendarsById.TryGetValue(localCalendar.RemoteCalendarId, out var remoteCalendar)) + continue; + + var remoteToken = !string.IsNullOrWhiteSpace(remoteCalendar.SyncToken) + ? remoteCalendar.SyncToken + : remoteCalendar.CTag; + + var isInitialSync = string.IsNullOrWhiteSpace(localCalendar.SynchronizationDeltaToken); + var tokenChanged = !string.Equals(localCalendar.SynchronizationDeltaToken, remoteToken, StringComparison.Ordinal); + var forceSync = options?.Type is CalendarSynchronizationType.ExecuteRequests or CalendarSynchronizationType.SingleCalendar; + + if (!isInitialSync && !tokenChanged && !forceSync) + continue; + + var remoteEvents = await _calDavClient.GetCalendarEventsAsync( + activeConnection, + remoteCalendar, + periodStartUtc, + periodEndUtc, + cancellationToken).ConfigureAwait(false); + + foreach (var remoteEvent in remoteEvents) + { + await _imapChangeProcessor + .ManageCalendarEventAsync(remoteEvent, localCalendar, Account) + .ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(remoteEvent.IcsContent)) + continue; + + var localItem = await _imapChangeProcessor + .GetCalendarItemAsync(localCalendar.Id, remoteEvent.RemoteEventId) + .ConfigureAwait(false); + + if (localItem == null) + continue; + + await _imapChangeProcessor + .SaveCalendarItemIcsAsync( + Account.Id, + localCalendar.Id, + localItem.Id, + remoteEvent.RemoteEventId, + remoteEvent.RemoteResourceHref, + remoteEvent.ETag, + remoteEvent.IcsContent) + .ConfigureAwait(false); + } + + localCalendar.SynchronizationDeltaToken = remoteToken; + await _imapChangeProcessor.UpdateAccountCalendarAsync(localCalendar).ConfigureAwait(false); + } + + return CalendarSynchronizationResult.Empty; + } + + private async Task ResolveCalDavServiceUriAsync(CancellationToken cancellationToken) + { + var explicitCalDavUri = TryGetExplicitCalDavServiceUri(); + if (explicitCalDavUri != null) + { + _cachedCalDavServiceUri = explicitCalDavUri; + _isCalDavDiscoveryAttempted = true; + return _cachedCalDavServiceUri; + } + + if (_cachedCalDavServiceUri != null) + return _cachedCalDavServiceUri; + + if (_isCalDavDiscoveryAttempted) + return null; + + await _calDavDiscoveryLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (_cachedCalDavServiceUri != null) + return _cachedCalDavServiceUri; + + if (_isCalDavDiscoveryAttempted) + return null; + + _isCalDavDiscoveryAttempted = true; + + var emailCandidates = new[] + { + Account.ServerInformation?.Address, + Account.Address + } + .Where(value => !string.IsNullOrWhiteSpace(value) && value.Contains('@')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var email in emailCandidates) + { + var discoveredUri = await _autoDiscoveryService + .DiscoverCalDavServiceUriAsync(email, cancellationToken) + .ConfigureAwait(false); + + if (discoveredUri == null) + continue; + + _cachedCalDavServiceUri = discoveredUri; + return _cachedCalDavServiceUri; + } + + if (Account.SpecialImapProvider == SpecialImapProvider.iCloud) + { + _cachedCalDavServiceUri = new Uri("https://caldav.icloud.com/"); + return _cachedCalDavServiceUri; + } + + if (Account.SpecialImapProvider == SpecialImapProvider.Yahoo) + { + _cachedCalDavServiceUri = new Uri("https://caldav.calendar.yahoo.com/"); + return _cachedCalDavServiceUri; + } + + return null; + } + finally + { + _calDavDiscoveryLock.Release(); + } + } + + private string ResolveCalDavPassword() + { + if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.CalDavPassword)) + return Account.ServerInformation.CalDavPassword; + + if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.IncomingServerPassword)) + return Account.ServerInformation.IncomingServerPassword; + + if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.OutgoingServerPassword)) + return Account.ServerInformation.OutgoingServerPassword; + + return string.Empty; + } + + private string ResolveCalDavUsername() + { + if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.CalDavUsername)) + return Account.ServerInformation.CalDavUsername.Trim(); + + if (!string.IsNullOrWhiteSpace(Account.ServerInformation?.Address)) + return Account.ServerInformation.Address.Trim(); + + if (!string.IsNullOrWhiteSpace(Account.Address)) + return Account.Address.Trim(); + + return string.Empty; + } + + private Uri TryGetExplicitCalDavServiceUri() + { + var configuredUrl = Account.ServerInformation?.CalDavServiceUrl; + if (string.IsNullOrWhiteSpace(configuredUrl)) + return null; + + if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var uri)) + { + _logger.Warning("Configured CalDAV URL is invalid for account {AccountName}: {Url}", Account.Name, configuredUrl); + return null; + } + + return uri; + } + + private async Task SynchronizeCalendarMetadataAsync(IReadOnlyList remoteCalendars) + { + var localCalendars = await _imapChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false); + var remoteCalendarsById = remoteCalendars + .GroupBy(c => c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId; + + foreach (var localCalendar in localCalendars.ToList()) + { + if (remoteCalendarsById.ContainsKey(localCalendar.RemoteCalendarId)) + continue; + + await _imapChangeProcessor + .DeleteCalendarIcsForCalendarAsync(Account.Id, localCalendar.Id) + .ConfigureAwait(false); + await _imapChangeProcessor.DeleteAccountCalendarAsync(localCalendar).ConfigureAwait(false); + localCalendars.Remove(localCalendar); + } + + foreach (var remoteCalendar in remoteCalendars) + { + var existingLocal = localCalendars.FirstOrDefault(c => + string.Equals(c.RemoteCalendarId, remoteCalendar.RemoteCalendarId, StringComparison.OrdinalIgnoreCase)); + + var isPrimary = string.Equals(remoteCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase); + + if (existingLocal == null) + { + var newCalendar = new AccountCalendar + { + Id = Guid.NewGuid(), + AccountId = Account.Id, + RemoteCalendarId = remoteCalendar.RemoteCalendarId, + Name = remoteCalendar.Name, + IsPrimary = isPrimary, + IsSynchronizationEnabled = true, + IsExtended = true, + TextColorHex = "#000000", + BackgroundColorHex = ColorHelpers.GenerateFlatColorHex(), + TimeZone = "UTC", + SynchronizationDeltaToken = string.Empty + }; + + await _imapChangeProcessor.InsertAccountCalendarAsync(newCalendar).ConfigureAwait(false); + continue; + } + + var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal) + || existingLocal.IsPrimary != isPrimary; + + if (!shouldUpdate) + continue; + + existingLocal.Name = remoteCalendar.Name; + existingLocal.IsPrimary = isPrimary; + await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false); + } + } public Task StartIdleClientAsync() { diff --git a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs index 4ba0394d..6c24aa07 100644 --- a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs @@ -15,6 +15,7 @@ using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Services; +using Wino.Mail.ViewModels.Data; using Wino.Messaging.Client.Calendar; using Wino.Messaging.Client.Navigation; @@ -91,7 +92,16 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel [RelayCommand] private void EditAccountDetails() - => Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsEditAccountDetails_Title, WinoPage.EditAccountDetailsPage, Account)); + { + if (Account?.ProviderType == MailProviderType.IMAP4) + { + var context = ImapCalDavSettingsNavigationContext.CreateForEditMode(Account.Id); + Messenger.Send(new BreadcrumbNavigationRequested(Translator.ImapCalDavSettingsPage_TitleEdit, WinoPage.ImapCalDavSettingsPage, context)); + return; + } + + Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsEditAccountDetails_Title, WinoPage.EditAccountDetailsPage, Account)); + } [RelayCommand] private async Task DeleteAccount() diff --git a/Wino.Mail.ViewModels/AccountManagementViewModel.cs b/Wino.Mail.ViewModels/AccountManagementViewModel.cs index 95d81833..7a81344f 100644 --- a/Wino.Mail.ViewModels/AccountManagementViewModel.cs +++ b/Wino.Mail.ViewModels/AccountManagementViewModel.cs @@ -13,7 +13,6 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Navigation; -using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Services; using Wino.Core.ViewModels; using Wino.Core.ViewModels.Data; @@ -25,8 +24,6 @@ namespace Wino.Mail.ViewModels; public partial class AccountManagementViewModel : AccountManagementPageViewModelBase { - private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver; - private readonly IImapTestService _imapTestService; private readonly IWinoLogger _winoLogger; public IMailDialogService MailDialogService { get; } @@ -34,17 +31,13 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel public AccountManagementViewModel(IMailDialogService dialogService, INavigationService navigationService, IAccountService accountService, - ISpecialImapProviderConfigResolver specialImapProviderConfigResolver, IProviderService providerService, - IImapTestService imapTestService, IStoreManagementService storeManagementService, IWinoLogger winoLogger, IAuthenticationProvider authenticationProvider, IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService) { MailDialogService = dialogService; - _specialImapProviderConfigResolver = specialImapProviderConfigResolver; - _imapTestService = imapTestService; _winoLogger = winoLogger; } @@ -95,12 +88,8 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel // Select provider. var accountCreationDialogResult = await MailDialogService.ShowAccountProviderSelectionDialogAsync(providers); - var accountCreationCancellationTokenSource = new CancellationTokenSource(); - if (accountCreationDialogResult != null) { - creationDialog = MailDialogService.GetAccountCreationDialog(accountCreationDialogResult); - CustomServerInformation customServerInformation = null; createdAccount = new MailAccount() @@ -113,76 +102,51 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel IsCalendarAccessGranted = true // New accounts have calendar scopes }; - await creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource); - await Task.Delay(500); - - creationDialog.State = AccountCreationDialogState.SigningIn; - - string tokenInformation = string.Empty; - - // Custom server implementation requires more async waiting. - if (creationDialog is IImapAccountCreationDialog customServerDialog) + if (accountCreationDialogResult.ProviderType == MailProviderType.IMAP4) { - // Pass along the account properties and perform initial navigation on the imap frame. - customServerDialog.StartImapConnectionSetup(createdAccount); + var completionSource = new TaskCompletionSource(); + var setupContext = ImapCalDavSettingsNavigationContext.CreateForCreateMode(accountCreationDialogResult, completionSource); - customServerInformation = await customServerDialog.GetCustomServerInformationAsync() + Messenger.Send(new BreadcrumbNavigationRequested( + Translator.ImapCalDavSettingsPage_TitleCreate, + WinoPage.ImapCalDavSettingsPage, + setupContext)); + + var setupResult = await completionSource.Task.ConfigureAwait(false) ?? throw new AccountSetupCanceledException(); - // At this point connection is successful. - // Save the server setup information and later on we'll fetch folders. - + customServerInformation = setupResult.ServerInformation ?? throw new AccountSetupCanceledException(); + customServerInformation.Id = Guid.NewGuid(); customServerInformation.AccountId = createdAccount.Id; - createdAccount.Address = customServerInformation.Address; + createdAccount.Address = setupResult.EmailAddress; + createdAccount.SenderName = setupResult.DisplayName; + createdAccount.IsCalendarAccessGranted = setupResult.IsCalendarAccessGranted; createdAccount.ServerInformation = customServerInformation; - createdAccount.SenderName = customServerInformation.DisplayName; } else { - // Hanle special imap providers like iCloud and Yahoo. - if (accountCreationDialogResult.SpecialImapProviderDetails != null) - { - // Special imap provider testing dialog. This is only available for iCloud and Yahoo. - customServerInformation = _specialImapProviderConfigResolver.GetServerInformation(createdAccount, accountCreationDialogResult); - customServerInformation.Id = Guid.NewGuid(); - customServerInformation.AccountId = createdAccount.Id; + var accountCreationCancellationTokenSource = new CancellationTokenSource(); + creationDialog = MailDialogService.GetAccountCreationDialog(accountCreationDialogResult); - createdAccount.SenderName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName; - createdAccount.Address = customServerInformation.Address; + await creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource); + await Task.Delay(500); - // Let server validate the imap/smtp connection. - // TODO: Protocol log with detailed failure. + creationDialog.State = AccountCreationDialogState.SigningIn; - await _imapTestService.TestImapConnectionAsync(customServerInformation, true); - //var testResultResponse = await WinoServerConnectionManager.GetResponseAsync(new ImapConnectivityTestRequested(customServerInformation, true)); + // OAuth authentication is handled here. + // Use SynchronizationManager to handle OAuth authentication. - //if (!testResultResponse.IsSuccess) - //{ - // throw new Exception($"{Translator.IMAPSetupDialog_ConnectionFailedTitle}\n{testResultResponse.Message}"); - //} - //else if (!testResultResponse.Data.IsSuccess) - //{ - // // Server connectivity might succeed, but result might be failed. - // throw new ImapClientPoolException(testResultResponse.Data.FailedReason, customServerInformation, testResultResponse.Data.FailureProtocolLog); - //} - } - else - { - // OAuth authentication is handled here. - // Use SynchronizationManager to handle OAuth authentication. + var authTokenInfo = await SynchronizationManager.Instance.HandleAuthorizationAsync( + accountCreationDialogResult.ProviderType, + createdAccount, + createdAccount.ProviderType == MailProviderType.Gmail); - var authTokenInfo = await SynchronizationManager.Instance.HandleAuthorizationAsync( - accountCreationDialogResult.ProviderType, - createdAccount, - createdAccount.ProviderType == MailProviderType.Gmail); + if (creationDialog.State == AccountCreationDialogState.Canceled) + throw new AccountSetupCanceledException(); - if (creationDialog.State == AccountCreationDialogState.Canceled) - throw new AccountSetupCanceledException(); - - // Update account address with authenticated user information - createdAccount.Address = authTokenInfo.AccountAddress; - } + // Update account address with authenticated user information + createdAccount.Address = authTokenInfo.AccountAddress; } // Address is still doesn't have a value for API synchronizers. @@ -198,12 +162,6 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel // Start profile information synchronization. // It's only available for Outlook and Gmail synchronizers. - var profileSyncOptions = new MailSynchronizationOptions() - { - AccountId = createdAccount.Id, - Type = MailSynchronizationType.UpdateProfile - }; - var profileSynchronizationResult = await SynchronizationManager.Instance.SynchronizeProfileAsync(createdAccount.Id); if (profileSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) @@ -223,18 +181,9 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel } } - if (creationDialog is IImapAccountCreationDialog customServerAccountCreationDialog) - customServerAccountCreationDialog.ShowPreparingFolders(); - else + if (creationDialog != null) creationDialog.State = AccountCreationDialogState.PreparingFolders; - // Start synchronizing folders. - var folderSyncOptions = new MailSynchronizationOptions() - { - AccountId = createdAccount.Id, - Type = MailSynchronizationType.FoldersOnly - }; - var folderSynchronizationResult = await SynchronizationManager.Instance.SynchronizeFoldersAsync(createdAccount.Id); if (folderSynchronizationResult == null || folderSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) diff --git a/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs b/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs new file mode 100644 index 00000000..b5a1372d --- /dev/null +++ b/Wino.Mail.ViewModels/Data/ImapCalDavSettingsNavigationContext.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Accounts; + +namespace Wino.Mail.ViewModels.Data; + +public enum ImapCalDavSettingsPageMode +{ + Create, + Edit +} + +public sealed class ImapCalDavSettingsNavigationContext +{ + public ImapCalDavSettingsPageMode Mode { get; init; } + public Guid AccountId { get; init; } + public AccountCreationDialogResult AccountCreationDialogResult { get; init; } + public TaskCompletionSource CompletionSource { get; init; } + + public static ImapCalDavSettingsNavigationContext CreateForCreateMode( + AccountCreationDialogResult accountCreationDialogResult, + TaskCompletionSource completionSource) + => new() + { + Mode = ImapCalDavSettingsPageMode.Create, + AccountCreationDialogResult = accountCreationDialogResult, + CompletionSource = completionSource + }; + + public static ImapCalDavSettingsNavigationContext CreateForEditMode(Guid accountId) + => new() + { + Mode = ImapCalDavSettingsPageMode.Edit, + AccountId = accountId + }; +} + +public sealed class ImapCalDavSetupResult +{ + public string DisplayName { get; init; } + public string EmailAddress { get; init; } + public bool IsCalendarAccessGranted { get; init; } + public CustomServerInformation ServerInformation { get; init; } +} + +public sealed class ImapCalendarSupportModeOption(ImapCalendarSupportMode mode, string title) +{ + public ImapCalendarSupportMode Mode { get; } = mode; + public string Title { get; } = title; +} diff --git a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs new file mode 100644 index 00000000..0e44d6da --- /dev/null +++ b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs @@ -0,0 +1,926 @@ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; +using Wino.Core.Domain.Models.AutoDiscovery; +using Wino.Core.Domain.Models.Calendar; +using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Services; +using Wino.Mail.ViewModels.Data; +using Wino.Messaging.Client.Navigation; + +namespace Wino.Mail.ViewModels; + +public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel +{ + private readonly IAutoDiscoveryService _autoDiscoveryService; + private readonly ICalDavClient _calDavClient; + private readonly IAccountService _accountService; + private readonly IMailDialogService _mailDialogService; + + private ImapCalDavSettingsPageMode _pageMode; + private Guid _editingAccountId; + private TaskCompletionSource _completionSource; + private bool _isCompletionFinalized; + private bool _localOnlyInfoShown; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCreateMode))] + [NotifyPropertyChangedFor(nameof(IsEditMode))] + private string pageTitle = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasProviderHint))] + private string providerHint = string.Empty; + + [ObservableProperty] + private string displayName = string.Empty; + + [ObservableProperty] + private string emailAddress = string.Empty; + + [ObservableProperty] + private string password = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCalendarModeSelectionVisible))] + [NotifyPropertyChangedFor(nameof(IsCalDavSettingsVisible))] + [NotifyPropertyChangedFor(nameof(IsLocalCalendarModeSelected))] + [NotifyPropertyChangedFor(nameof(SelectedCalendarSupportDescription))] + private bool isCalendarSupportEnabled = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsCalDavSettingsVisible))] + [NotifyPropertyChangedFor(nameof(IsLocalCalendarModeSelected))] + [NotifyPropertyChangedFor(nameof(SelectedCalendarSupportDescription))] + [NotifyPropertyChangedFor(nameof(SelectedCalendarSupportModeIndex))] + private ImapCalendarSupportMode selectedCalendarSupportMode = ImapCalendarSupportMode.CalDav; + + [ObservableProperty] + private string incomingServer = string.Empty; + + [ObservableProperty] + private string incomingServerPort = string.Empty; + + [ObservableProperty] + private string incomingServerUsername = string.Empty; + + [ObservableProperty] + private string incomingServerPassword = string.Empty; + + [ObservableProperty] + private string outgoingServer = string.Empty; + + [ObservableProperty] + private string outgoingServerPort = string.Empty; + + [ObservableProperty] + private string outgoingServerUsername = string.Empty; + + [ObservableProperty] + private string outgoingServerPassword = string.Empty; + + [ObservableProperty] + private string proxyServer = string.Empty; + + [ObservableProperty] + private string proxyServerPort = string.Empty; + + [ObservableProperty] + private string calDavServiceUrl = string.Empty; + + [ObservableProperty] + private string calDavUsername = string.Empty; + + [ObservableProperty] + private string calDavPassword = string.Empty; + + [ObservableProperty] + private int maxConcurrentClients = 5; + + [ObservableProperty] + private bool isImapValidationSucceeded; + + [ObservableProperty] + private bool isCalDavValidationSucceeded; + + [ObservableProperty] + private int selectedIncomingServerConnectionSecurityIndex; + + [ObservableProperty] + private int selectedIncomingServerAuthenticationMethodIndex; + + [ObservableProperty] + private int selectedOutgoingServerConnectionSecurityIndex; + + [ObservableProperty] + private int selectedOutgoingServerAuthenticationMethodIndex; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBasicSetupSelected))] + [NotifyPropertyChangedFor(nameof(IsAdvancedSetupSelected))] + private int selectedSetupTabIndex; + + public bool IsCreateMode => _pageMode == ImapCalDavSettingsPageMode.Create; + public bool IsEditMode => !IsCreateMode; + public bool HasProviderHint => !string.IsNullOrWhiteSpace(ProviderHint); + public bool IsBasicSetupSelected => SelectedSetupTabIndex == 0; + public bool IsAdvancedSetupSelected => SelectedSetupTabIndex == 1; + public bool IsCalendarModeSelectionVisible => IsCalendarSupportEnabled; + public bool IsCalDavSettingsVisible => IsCalendarSupportEnabled && SelectedCalendarSupportMode == ImapCalendarSupportMode.CalDav; + public bool IsLocalCalendarModeSelected => IsCalendarSupportEnabled && SelectedCalendarSupportMode == ImapCalendarSupportMode.LocalOnly; + public string SubtitleText => Translator.ImapCalDavSettingsPage_Subtitle; + public string BasicSectionTitleText => Translator.ImapCalDavSettingsPage_BasicSectionTitle; + public string BasicSectionDescriptionText => Translator.ImapCalDavSettingsPage_BasicSectionDescription; + public string DisplayNameHeaderText => Translator.IMAPSetupDialog_DisplayName; + public string DisplayNamePlaceholderText => Translator.IMAPSetupDialog_DisplayNamePlaceholder; + public string EmailAddressHeaderText => Translator.IMAPSetupDialog_MailAddress; + public string EmailAddressPlaceholderText => Translator.IMAPSetupDialog_MailAddressPlaceholder; + public string PasswordHeaderText => Translator.IMAPSetupDialog_Password; + public string EnableCalendarSupportText => Translator.ImapCalDavSettingsPage_EnableCalendarSupport; + public string AutoDiscoverButtonText => Translator.ImapCalDavSettingsPage_AutoDiscoverButton; + public string BasicTabText => Translator.ImapCalDavSettingsPage_BasicTab; + public string AdvancedTabText => Translator.ImapCalDavSettingsPage_AdvancedTab; + public string AdvancedSectionTitleText => Translator.ImapCalDavSettingsPage_AdvancedSectionTitle; + public string AdvancedSectionDescriptionText => Translator.ImapCalDavSettingsPage_AdvancedSectionDescription; + public string IncomingSectionTitleText => Translator.IMAPSetupDialog_IMAPSettings; + public string IncomingServerHeaderText => Translator.IMAPSetupDialog_IncomingMailServer; + public string PortHeaderText => Translator.IMAPSetupDialog_IncomingMailServerPort; + public string IncomingUsernameHeaderText => Translator.IMAPSetupDialog_Username; + public string IncomingPasswordHeaderText => Translator.IMAPSetupDialog_Password; + public string OutgoingSectionTitleText => Translator.IMAPSetupDialog_SMTPSettings; + public string OutgoingServerHeaderText => Translator.IMAPSetupDialog_OutgoingMailServer; + public string OutgoingUsernameHeaderText => Translator.IMAPSetupDialog_OutgoingMailServerUsername; + public string OutgoingPasswordHeaderText => Translator.IMAPSetupDialog_OutgoingMailServerPassword; + public string ConnectionSecurityHeaderText => Translator.ImapCalDavSettingsPage_ConnectionSecurityHeader; + public string AuthenticationMethodHeaderText => Translator.ImapCalDavSettingsPage_AuthenticationMethodHeader; + public string CalendarSectionTitleText => Translator.ImapCalDavSettingsPage_CalendarSectionTitle; + public string CalendarSectionDescriptionText => Translator.ImapCalDavSettingsPage_CalendarSectionDescription; + public string CalendarModeHeaderText => Translator.ImapCalDavSettingsPage_CalendarModeHeader; + public string LocalCalendarLearnMoreText => Translator.ImapCalDavSettingsPage_LocalCalendarLearnMore; + public string CalDavServiceUrlHeaderText => Translator.ImapCalDavSettingsPage_CalDavServiceUrl; + public string CalDavUsernameHeaderText => Translator.ImapCalDavSettingsPage_CalDavUsername; + public string CalDavPasswordHeaderText => Translator.ImapCalDavSettingsPage_CalDavPassword; + public string TestImapButtonText => Translator.ImapCalDavSettingsPage_TestImapButton; + public string TestCalDavButtonText => Translator.ImapCalDavSettingsPage_TestCalDavButton; + public string SaveButtonText => Translator.Buttons_Save; + public string CancelButtonText => Translator.Buttons_Cancel; + + public string SelectedCalendarSupportDescription => SelectedCalendarSupportMode switch + { + ImapCalendarSupportMode.CalDav => Translator.ImapCalDavSettingsPage_CalendarModeCalDavDescription, + ImapCalendarSupportMode.LocalOnly => Translator.ImapCalDavSettingsPage_CalendarModeLocalOnlyDescription, + _ => Translator.ImapCalDavSettingsPage_CalendarModeDisabledDescription + }; + + public List AvailableAuthenticationMethods { get; } = + [ + new ImapAuthenticationMethodModel(ImapAuthenticationMethod.Auto, Translator.ImapAuthenticationMethod_Auto), + new ImapAuthenticationMethodModel(ImapAuthenticationMethod.None, Translator.ImapAuthenticationMethod_None), + new ImapAuthenticationMethodModel(ImapAuthenticationMethod.NormalPassword, Translator.ImapAuthenticationMethod_Plain), + new ImapAuthenticationMethodModel(ImapAuthenticationMethod.EncryptedPassword, Translator.ImapAuthenticationMethod_EncryptedPassword), + new ImapAuthenticationMethodModel(ImapAuthenticationMethod.Ntlm, Translator.ImapAuthenticationMethod_Ntlm), + new ImapAuthenticationMethodModel(ImapAuthenticationMethod.CramMd5, Translator.ImapAuthenticationMethod_CramMD5), + new ImapAuthenticationMethodModel(ImapAuthenticationMethod.DigestMd5, Translator.ImapAuthenticationMethod_DigestMD5) + ]; + + public List AvailableConnectionSecurities { get; } = + [ + new ImapConnectionSecurityModel(ImapConnectionSecurity.Auto, Translator.ImapConnectionSecurity_Auto), + new ImapConnectionSecurityModel(ImapConnectionSecurity.SslTls, Translator.ImapConnectionSecurity_SslTls), + new ImapConnectionSecurityModel(ImapConnectionSecurity.StartTls, Translator.ImapConnectionSecurity_StartTls), + new ImapConnectionSecurityModel(ImapConnectionSecurity.None, Translator.ImapConnectionSecurity_None) + ]; + + public List AvailableConnectionSecurityDisplayNames { get; } = + [ + Translator.ImapConnectionSecurity_Auto, + Translator.ImapConnectionSecurity_SslTls, + Translator.ImapConnectionSecurity_StartTls, + Translator.ImapConnectionSecurity_None + ]; + + public List AvailableCalendarSupportModes { get; } = + [ + new ImapCalendarSupportModeOption(ImapCalendarSupportMode.CalDav, Translator.ImapCalDavSettingsPage_CalendarModeCalDav), + new ImapCalendarSupportModeOption(ImapCalendarSupportMode.LocalOnly, Translator.ImapCalDavSettingsPage_CalendarModeLocalOnly), + new ImapCalendarSupportModeOption(ImapCalendarSupportMode.Disabled, Translator.ImapCalDavSettingsPage_CalendarModeDisabled) + ]; + + public List AvailableAuthenticationMethodDisplayNames { get; } = + [ + Translator.ImapAuthenticationMethod_Auto, + Translator.ImapAuthenticationMethod_None, + Translator.ImapAuthenticationMethod_Plain, + Translator.ImapAuthenticationMethod_EncryptedPassword, + Translator.ImapAuthenticationMethod_Ntlm, + Translator.ImapAuthenticationMethod_CramMD5, + Translator.ImapAuthenticationMethod_DigestMD5 + ]; + + public List AvailableCalendarSupportModeTitles { get; } = + [ + Translator.ImapCalDavSettingsPage_CalendarModeCalDav, + Translator.ImapCalDavSettingsPage_CalendarModeLocalOnly, + Translator.ImapCalDavSettingsPage_CalendarModeDisabled + ]; + + public int SelectedCalendarSupportModeIndex + { + get + { + var index = AvailableCalendarSupportModes.FindIndex(a => a.Mode == SelectedCalendarSupportMode); + return index < 0 ? 0 : index; + } + set + { + if (value < 0 || value >= AvailableCalendarSupportModes.Count) + return; + + var selectedMode = AvailableCalendarSupportModes[value].Mode; + if (selectedMode != SelectedCalendarSupportMode) + { + SelectedCalendarSupportMode = selectedMode; + } + } + } + + public ImapCalDavSettingsPageViewModel(IAutoDiscoveryService autoDiscoveryService, + ICalDavClient calDavClient, + IAccountService accountService, + IMailDialogService mailDialogService) + { + _autoDiscoveryService = autoDiscoveryService; + _calDavClient = calDavClient; + _accountService = accountService; + _mailDialogService = mailDialogService; + } + + public override async void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + + if (parameters is not ImapCalDavSettingsNavigationContext context) + return; + + _pageMode = context.Mode; + _editingAccountId = context.AccountId; + _completionSource = context.CompletionSource; + _isCompletionFinalized = false; + _localOnlyInfoShown = false; + SelectedSetupTabIndex = 0; + + if (_pageMode == ImapCalDavSettingsPageMode.Create) + { + PageTitle = Translator.ImapCalDavSettingsPage_TitleCreate; + ApplyCreateContextDefaults(context.AccountCreationDialogResult); + } + else + { + PageTitle = Translator.ImapCalDavSettingsPage_TitleEdit; + await InitializeEditModeAsync(context.AccountId).ConfigureAwait(false); + } + } + + public override void OnNavigatedFrom(NavigationMode mode, object parameters) + { + if (_pageMode == ImapCalDavSettingsPageMode.Create && !_isCompletionFinalized) + { + _completionSource?.TrySetResult(null); + _isCompletionFinalized = true; + } + + base.OnNavigatedFrom(mode, parameters); + } + + [RelayCommand] + private async Task AutoDiscoverSettingsAsync() + { + try + { + var minimalSettings = BuildMinimalSettingsOrThrow(); + await AutoDiscoverAndApplySettingsAsync(minimalSettings).ConfigureAwait(false); + + _mailDialogService.InfoBarMessage( + Translator.IMAPSetupDialog_ValidationSuccess_Title, + Translator.ImapCalDavSettingsPage_AutoDiscoverySuccessMessage, + InfoBarMessageType.Success); + } + catch (Exception ex) + { + _mailDialogService.InfoBarMessage( + Translator.IMAPSetupDialog_ValidationFailed_Title, + ex.Message, + InfoBarMessageType.Error); + } + } + + [RelayCommand] + private async Task TestImapConnectionAsync() + { + try + { + await EnsureImapSettingsPreparedAsync().ConfigureAwait(false); + var serverInformation = BuildServerInformation(); + + ValidateImapSettings(serverInformation); + await ValidateImapConnectivityAsync(serverInformation).ConfigureAwait(false); + + IsImapValidationSucceeded = true; + + _mailDialogService.InfoBarMessage( + Translator.IMAPSetupDialog_ValidationSuccess_Title, + Translator.ImapCalDavSettingsPage_ImapTestSuccessMessage, + InfoBarMessageType.Success); + } + catch (Exception ex) + { + IsImapValidationSucceeded = false; + + _mailDialogService.InfoBarMessage( + Translator.IMAPSetupDialog_ValidationFailed_Title, + ex.Message, + InfoBarMessageType.Error); + } + } + + [RelayCommand] + private async Task TestCalDavConnectionAsync() + { + try + { + if (!IsCalendarSupportEnabled || SelectedCalendarSupportMode != ImapCalendarSupportMode.CalDav) + throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavNotRequiredMessage); + + var serverInformation = BuildServerInformation(); + ValidateCalDavSettings(serverInformation); + await ValidateCalDavConnectivityAsync(serverInformation).ConfigureAwait(false); + + IsCalDavValidationSucceeded = true; + + _mailDialogService.InfoBarMessage( + Translator.IMAPSetupDialog_ValidationSuccess_Title, + Translator.ImapCalDavSettingsPage_CalDavTestSuccessMessage, + InfoBarMessageType.Success); + } + catch (Exception ex) + { + IsCalDavValidationSucceeded = false; + + _mailDialogService.InfoBarMessage( + Translator.IMAPSetupDialog_ValidationFailed_Title, + ex.Message, + InfoBarMessageType.Error); + } + } + [RelayCommand] + private async Task SaveAsync() + { + try + { + await EnsureImapSettingsPreparedAsync().ConfigureAwait(false); + + var serverInformation = BuildServerInformation(); + + ValidateIdentitySettings(); + ValidateImapSettings(serverInformation); + ValidateCalendarModeSpecificSettings(serverInformation); + + await ValidateImapConnectivityAsync(serverInformation).ConfigureAwait(false); + IsImapValidationSucceeded = true; + + if (serverInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav) + { + await ValidateCalDavConnectivityAsync(serverInformation).ConfigureAwait(false); + IsCalDavValidationSucceeded = true; + } + else + { + IsCalDavValidationSucceeded = false; + } + + if (_pageMode == ImapCalDavSettingsPageMode.Create) + { + CompleteCreateFlow(serverInformation); + return; + } + + await SaveEditFlowAsync(serverInformation).ConfigureAwait(false); + } + catch (Exception ex) + { + _mailDialogService.InfoBarMessage( + Translator.IMAPSetupDialog_ValidationFailed_Title, + ex.Message, + InfoBarMessageType.Error); + } + } + + [RelayCommand] + private void Cancel() + { + if (_pageMode == ImapCalDavSettingsPageMode.Create && !_isCompletionFinalized) + { + _completionSource?.TrySetResult(null); + _isCompletionFinalized = true; + } + + Messenger.Send(new BackBreadcrumNavigationRequested()); + } + + [RelayCommand] + private Task ShowLocalCalendarExplanationAsync() + => _mailDialogService.ShowMessageAsync( + Translator.ImapCalDavSettingsPage_LocalCalendarDialogMessage, + Translator.ImapCalDavSettingsPage_LocalCalendarDialogTitle, + WinoCustomMessageDialogIcon.Information); + + partial void OnIsCalendarSupportEnabledChanged(bool value) + { + if (!value && SelectedCalendarSupportMode != ImapCalendarSupportMode.Disabled) + { + SelectedCalendarSupportMode = ImapCalendarSupportMode.Disabled; + } + else if (value && SelectedCalendarSupportMode == ImapCalendarSupportMode.Disabled) + { + SelectedCalendarSupportMode = ImapCalendarSupportMode.CalDav; + } + } + + partial void OnSelectedCalendarSupportModeChanged(ImapCalendarSupportMode value) + { + if (value == ImapCalendarSupportMode.LocalOnly && !_localOnlyInfoShown) + { + _localOnlyInfoShown = true; + _ = ShowLocalCalendarExplanationAsync(); + } + + if (value != ImapCalendarSupportMode.CalDav) + { + IsCalDavValidationSucceeded = false; + } + } + + private async Task InitializeEditModeAsync(Guid accountId) + { + var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); + if (account == null) + throw new InvalidOperationException(Translator.Exception_NullAssignedAccount); + + DisplayName = account.SenderName ?? string.Empty; + EmailAddress = account.Address ?? string.Empty; + + ApplyServerInformation(account.ServerInformation); + + if (account.ServerInformation != null) + { + SelectedCalendarSupportMode = account.ServerInformation.CalendarSupportMode; + } + + if (SelectedCalendarSupportMode == ImapCalendarSupportMode.Disabled && account.IsCalendarAccessGranted) + { + SelectedCalendarSupportMode = ImapCalendarSupportMode.CalDav; + } + + IsCalendarSupportEnabled = SelectedCalendarSupportMode != ImapCalendarSupportMode.Disabled; + } + + private void ApplyCreateContextDefaults(AccountCreationDialogResult accountCreationDialogResult) + { + DisplayName = accountCreationDialogResult?.AccountName ?? string.Empty; + EmailAddress = accountCreationDialogResult?.SpecialImapProviderDetails?.Address ?? string.Empty; + Password = accountCreationDialogResult?.SpecialImapProviderDetails?.Password ?? string.Empty; + var normalizedEmail = !string.IsNullOrWhiteSpace(EmailAddress) && !EmailAddress.Contains('@') + ? $"{EmailAddress}@icloud.com" + : EmailAddress; + + if (!string.IsNullOrWhiteSpace(accountCreationDialogResult?.SpecialImapProviderDetails?.SenderName)) + DisplayName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName; + + IsCalendarSupportEnabled = true; + SelectedCalendarSupportMode = ImapCalendarSupportMode.CalDav; + + var specialProvider = accountCreationDialogResult?.SpecialImapProviderDetails?.SpecialImapProvider ?? SpecialImapProvider.None; + switch (specialProvider) + { + case SpecialImapProvider.iCloud: + ProviderHint = Translator.ImapCalDavSettingsPage_ICloudHint; + ApplySpecialProviderDefaults( + "imap.mail.me.com", + "993", + normalizedEmail, + "smtp.mail.me.com", + "587", + normalizedEmail, + Password, + "https://caldav.icloud.com/", + normalizedEmail, + Password); + break; + case SpecialImapProvider.Yahoo: + ProviderHint = Translator.ImapCalDavSettingsPage_YahooHint; + ApplySpecialProviderDefaults( + "imap.mail.yahoo.com", + "993", + EmailAddress, + "smtp.mail.yahoo.com", + "587", + EmailAddress, + Password, + "https://caldav.calendar.yahoo.com/", + EmailAddress, + Password); + break; + default: + ProviderHint = string.Empty; + break; + } + } + + private void ApplySpecialProviderDefaults(string incomingServer, + string incomingPort, + string incomingUsername, + string outgoingServer, + string outgoingPort, + string outgoingUsername, + string password, + string calDavServiceUrl, + string calDavUsername, + string calDavPassword) + { + IncomingServer = incomingServer; + IncomingServerPort = incomingPort; + IncomingServerUsername = incomingUsername; + IncomingServerPassword = password; + + OutgoingServer = outgoingServer; + OutgoingServerPort = outgoingPort; + OutgoingServerUsername = outgoingUsername; + OutgoingServerPassword = password; + CalDavServiceUrl = calDavServiceUrl; + CalDavUsername = calDavUsername; + CalDavPassword = calDavPassword; + + SelectedIncomingServerConnectionSecurityIndex = 0; + SelectedIncomingServerAuthenticationMethodIndex = 0; + SelectedOutgoingServerConnectionSecurityIndex = 0; + SelectedOutgoingServerAuthenticationMethodIndex = 0; + } + + private void ApplyServerInformation(CustomServerInformation serverInformation) + { + if (serverInformation == null) + return; + + IncomingServer = serverInformation.IncomingServer ?? string.Empty; + IncomingServerPort = serverInformation.IncomingServerPort ?? string.Empty; + IncomingServerUsername = serverInformation.IncomingServerUsername ?? string.Empty; + IncomingServerPassword = serverInformation.IncomingServerPassword ?? string.Empty; + + OutgoingServer = serverInformation.OutgoingServer ?? string.Empty; + OutgoingServerPort = serverInformation.OutgoingServerPort ?? string.Empty; + OutgoingServerUsername = serverInformation.OutgoingServerUsername ?? string.Empty; + OutgoingServerPassword = serverInformation.OutgoingServerPassword ?? string.Empty; + + ProxyServer = serverInformation.ProxyServer ?? string.Empty; + ProxyServerPort = serverInformation.ProxyServerPort ?? string.Empty; + MaxConcurrentClients = serverInformation.MaxConcurrentClients <= 0 ? 5 : serverInformation.MaxConcurrentClients; + + CalDavServiceUrl = serverInformation.CalDavServiceUrl ?? string.Empty; + CalDavUsername = serverInformation.CalDavUsername ?? string.Empty; + CalDavPassword = serverInformation.CalDavPassword ?? string.Empty; + + if (string.IsNullOrWhiteSpace(CalDavUsername)) + CalDavUsername = EmailAddress; + + if (string.IsNullOrWhiteSpace(CalDavPassword)) + CalDavPassword = IncomingServerPassword; + + SelectedIncomingServerAuthenticationMethodIndex = FindAuthenticationMethodIndex(serverInformation.IncomingAuthenticationMethod); + SelectedIncomingServerConnectionSecurityIndex = FindConnectionSecurityIndex(serverInformation.IncomingServerSocketOption); + SelectedOutgoingServerAuthenticationMethodIndex = FindAuthenticationMethodIndex(serverInformation.OutgoingAuthenticationMethod); + SelectedOutgoingServerConnectionSecurityIndex = FindConnectionSecurityIndex(serverInformation.OutgoingServerSocketOption); + } + + private async Task EnsureImapSettingsPreparedAsync() + { + if (HasCompleteImapSettings()) + return; + + var minimalSettings = BuildMinimalSettingsOrThrow(); + await AutoDiscoverAndApplySettingsAsync(minimalSettings).ConfigureAwait(false); + + if (!HasCompleteImapSettings()) + throw new InvalidOperationException(Translator.Exception_ImapAutoDiscoveryFailed); + } + + private async Task AutoDiscoverAndApplySettingsAsync(AutoDiscoveryMinimalSettings minimalSettings) + { + var discoverySettings = await _autoDiscoveryService.GetAutoDiscoverySettings(minimalSettings).ConfigureAwait(false); + + if (discoverySettings == null) + throw new InvalidOperationException(Translator.Exception_ImapAutoDiscoveryFailed); + + discoverySettings.UserMinimalSettings = minimalSettings; + + var serverInformation = discoverySettings.ToServerInformation(); + if (serverInformation == null) + throw new InvalidOperationException(Translator.Exception_ImapAutoDiscoveryFailed); + + ApplyServerInformation(serverInformation); + + if (IsCalendarSupportEnabled && SelectedCalendarSupportMode == ImapCalendarSupportMode.CalDav) + { + var discoveredCalDavUri = await _autoDiscoveryService.DiscoverCalDavServiceUriAsync(minimalSettings.Email).ConfigureAwait(false); + if (discoveredCalDavUri != null) + { + CalDavServiceUrl = discoveredCalDavUri.ToString(); + } + + if (string.IsNullOrWhiteSpace(CalDavUsername)) + CalDavUsername = minimalSettings.Email; + + if (string.IsNullOrWhiteSpace(CalDavPassword)) + CalDavPassword = minimalSettings.Password; + } + } + private async Task ValidateImapConnectivityAsync(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 _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); + } + + private async Task ValidateCalDavConnectivityAsync(CustomServerInformation serverInformation) + { + ValidateCalDavSettings(serverInformation); + + var uri = new Uri(serverInformation.CalDavServiceUrl, UriKind.Absolute); + var username = serverInformation.CalDavUsername; + var password = serverInformation.CalDavPassword; + + var settings = new CalDavConnectionSettings + { + ServiceUri = uri, + Username = username, + Password = password + }; + + await _calDavClient.DiscoverCalendarsAsync(settings).ConfigureAwait(false); + } + + private void CompleteCreateFlow(CustomServerInformation serverInformation) + { + if (_completionSource == null || _isCompletionFinalized) + return; + + serverInformation.Id = Guid.NewGuid(); + serverInformation.AccountId = Guid.Empty; + + _completionSource.TrySetResult(new ImapCalDavSetupResult + { + DisplayName = DisplayName.Trim(), + EmailAddress = EmailAddress.Trim(), + IsCalendarAccessGranted = serverInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav, + ServerInformation = serverInformation + }); + + _isCompletionFinalized = true; + + _mailDialogService.InfoBarMessage( + Translator.IMAPSetupDialog_ValidationSuccess_Title, + Translator.ImapCalDavSettingsPage_SaveSuccessMessage, + InfoBarMessageType.Success); + + Messenger.Send(new BackBreadcrumNavigationRequested()); + } + + private async Task SaveEditFlowAsync(CustomServerInformation serverInformation) + { + var account = await _accountService.GetAccountAsync(_editingAccountId).ConfigureAwait(false); + if (account == null) + throw new InvalidOperationException(Translator.Exception_NullAssignedAccount); + + account.SenderName = DisplayName.Trim(); + account.Address = EmailAddress.Trim(); + account.IsCalendarAccessGranted = serverInformation.CalendarSupportMode == ImapCalendarSupportMode.CalDav; + + serverInformation.Id = account.ServerInformation?.Id ?? Guid.NewGuid(); + serverInformation.AccountId = account.Id; + + account.ServerInformation = serverInformation; + + await _accountService.UpdateAccountCustomServerInformationAsync(serverInformation).ConfigureAwait(false); + await _accountService.UpdateAccountAsync(account).ConfigureAwait(false); + + _mailDialogService.InfoBarMessage( + Translator.IMAPSetupDialog_ValidationSuccess_Title, + Translator.ImapCalDavSettingsPage_SaveSuccessMessage, + InfoBarMessageType.Success); + + Messenger.Send(new BackBreadcrumNavigationRequested()); + } + + private AutoDiscoveryMinimalSettings BuildMinimalSettingsOrThrow() + { + ValidateIdentitySettings(); + + if (string.IsNullOrWhiteSpace(Password)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationPasswordRequired); + + return new AutoDiscoveryMinimalSettings + { + DisplayName = DisplayName.Trim(), + Email = EmailAddress.Trim(), + Password = Password + }; + } + + private CustomServerInformation BuildServerInformation() + { + var incomingAuth = GetAuthenticationMethodByIndex(SelectedIncomingServerAuthenticationMethodIndex); + var incomingSecurity = GetConnectionSecurityByIndex(SelectedIncomingServerConnectionSecurityIndex); + var outgoingAuth = GetAuthenticationMethodByIndex(SelectedOutgoingServerAuthenticationMethodIndex); + var outgoingSecurity = GetConnectionSecurityByIndex(SelectedOutgoingServerConnectionSecurityIndex); + + var mode = IsCalendarSupportEnabled ? SelectedCalendarSupportMode : ImapCalendarSupportMode.Disabled; + + var calDavUser = (CalDavUsername ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(calDavUser)) + calDavUser = (EmailAddress ?? string.Empty).Trim(); + + var calDavPassword = string.IsNullOrWhiteSpace(CalDavPassword) + ? IncomingServerPassword + : CalDavPassword; + + return new CustomServerInformation + { + Id = Guid.NewGuid(), + Address = (EmailAddress ?? string.Empty).Trim(), + IncomingServer = (IncomingServer ?? string.Empty).Trim(), + IncomingServerPort = (IncomingServerPort ?? string.Empty).Trim(), + IncomingServerUsername = (IncomingServerUsername ?? string.Empty).Trim(), + IncomingServerPassword = IncomingServerPassword ?? string.Empty, + IncomingServerType = CustomIncomingServerType.IMAP4, + IncomingAuthenticationMethod = incomingAuth, + IncomingServerSocketOption = incomingSecurity, + OutgoingServer = (OutgoingServer ?? string.Empty).Trim(), + OutgoingServerPort = (OutgoingServerPort ?? string.Empty).Trim(), + OutgoingServerUsername = (OutgoingServerUsername ?? string.Empty).Trim(), + OutgoingServerPassword = OutgoingServerPassword ?? string.Empty, + OutgoingAuthenticationMethod = outgoingAuth, + OutgoingServerSocketOption = outgoingSecurity, + ProxyServer = (ProxyServer ?? string.Empty).Trim(), + ProxyServerPort = (ProxyServerPort ?? string.Empty).Trim(), + MaxConcurrentClients = MaxConcurrentClients <= 0 ? 5 : MaxConcurrentClients, + CalendarSupportMode = mode, + CalDavServiceUrl = mode == ImapCalendarSupportMode.CalDav ? (CalDavServiceUrl ?? string.Empty).Trim() : string.Empty, + CalDavUsername = mode == ImapCalendarSupportMode.CalDav ? calDavUser : string.Empty, + CalDavPassword = mode == ImapCalendarSupportMode.CalDav ? calDavPassword : string.Empty + }; + } + + private void ValidateIdentitySettings() + { + if (string.IsNullOrWhiteSpace(DisplayName)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationDisplayNameRequired); + + if (string.IsNullOrWhiteSpace(EmailAddress)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationEmailRequired); + + if (!EmailValidation.EmailValidator.Validate(EmailAddress.Trim())) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationEmailInvalid); + } + + private static bool IsValidPort(string portText) + => int.TryParse(portText, out var value) && value > 0 && value <= 65535; + + private void ValidateImapSettings(CustomServerInformation serverInformation) + { + ValidateIdentitySettings(); + + if (string.IsNullOrWhiteSpace(serverInformation.IncomingServer)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationIncomingServerRequired); + + if (string.IsNullOrWhiteSpace(serverInformation.IncomingServerPort)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationIncomingPortRequired); + + if (!IsValidPort(serverInformation.IncomingServerPort)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationIncomingPortInvalid); + + if (string.IsNullOrWhiteSpace(serverInformation.IncomingServerUsername)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationUsernameRequired); + + if (string.IsNullOrWhiteSpace(serverInformation.IncomingServerPassword)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationPasswordRequired); + + if (string.IsNullOrWhiteSpace(serverInformation.OutgoingServer)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingServerRequired); + + if (string.IsNullOrWhiteSpace(serverInformation.OutgoingServerPort)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingPortRequired); + + if (!IsValidPort(serverInformation.OutgoingServerPort)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingPortInvalid); + + if (string.IsNullOrWhiteSpace(serverInformation.OutgoingServerUsername)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingUsernameRequired); + + if (string.IsNullOrWhiteSpace(serverInformation.OutgoingServerPassword)) + throw new InvalidOperationException(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingPasswordRequired); + } + + private void ValidateCalendarModeSpecificSettings(CustomServerInformation serverInformation) + { + if (serverInformation.CalendarSupportMode != ImapCalendarSupportMode.CalDav) + return; + + ValidateCalDavSettings(serverInformation); + } + + private void ValidateCalDavSettings(CustomServerInformation serverInformation) + { + if (string.IsNullOrWhiteSpace(serverInformation.CalDavServiceUrl)) + throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavUrlRequired); + + if (!Uri.TryCreate(serverInformation.CalDavServiceUrl, UriKind.Absolute, out _)) + throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavUrlInvalid); + + if (string.IsNullOrWhiteSpace(serverInformation.CalDavUsername)) + throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavUsernameRequired); + + if (string.IsNullOrWhiteSpace(serverInformation.CalDavPassword)) + throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavPasswordRequired); + } + private bool HasCompleteImapSettings() + => !string.IsNullOrWhiteSpace(IncomingServer) + && !string.IsNullOrWhiteSpace(IncomingServerPort) + && !string.IsNullOrWhiteSpace(IncomingServerUsername) + && !string.IsNullOrWhiteSpace(IncomingServerPassword) + && !string.IsNullOrWhiteSpace(OutgoingServer) + && !string.IsNullOrWhiteSpace(OutgoingServerPort) + && !string.IsNullOrWhiteSpace(OutgoingServerUsername) + && !string.IsNullOrWhiteSpace(OutgoingServerPassword) + && IsValidPort(IncomingServerPort) + && IsValidPort(OutgoingServerPort); + + private int FindAuthenticationMethodIndex(ImapAuthenticationMethod method) + { + var index = AvailableAuthenticationMethods.FindIndex(a => a.ImapAuthenticationMethod == method); + return index < 0 ? 0 : index; + } + + private int FindConnectionSecurityIndex(ImapConnectionSecurity security) + { + var index = AvailableConnectionSecurities.FindIndex(a => a.ImapConnectionSecurity == security); + return index < 0 ? 0 : index; + } + + private ImapAuthenticationMethod GetAuthenticationMethodByIndex(int index) + { + if (index < 0 || index >= AvailableAuthenticationMethods.Count) + return ImapAuthenticationMethod.Auto; + + return AvailableAuthenticationMethods[index].ImapAuthenticationMethod; + } + + private ImapConnectionSecurity GetConnectionSecurityByIndex(int index) + { + if (index < 0 || index >= AvailableConnectionSecurities.Count) + return ImapConnectionSecurity.Auto; + + return AvailableConnectionSecurities[index].ImapConnectionSecurity; + } +} diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index a711a7e4..46682b68 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -92,6 +92,7 @@ public partial class App : WinoApplication, services.AddTransient(typeof(IdlePageViewModel)); services.AddTransient(typeof(EditAccountDetailsPageViewModel)); + services.AddTransient(typeof(ImapCalDavSettingsPageViewModel)); services.AddTransient(typeof(AccountDetailsPageViewModel)); services.AddTransient(typeof(SignatureManagementPageViewModel)); services.AddTransient(typeof(MessageListPageViewModel)); diff --git a/Wino.Mail.WinUI/Dialogs/NewImapSetupDialog.xaml b/Wino.Mail.WinUI/Dialogs/NewImapSetupDialog.xaml deleted file mode 100644 index d6730686..00000000 --- a/Wino.Mail.WinUI/Dialogs/NewImapSetupDialog.xaml +++ /dev/null @@ -1,25 +0,0 @@ - - - - 0,0,0,0 - - 1920 - - - - - diff --git a/Wino.Mail.WinUI/Dialogs/NewImapSetupDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/NewImapSetupDialog.xaml.cs deleted file mode 100644 index 034942c2..00000000 --- a/Wino.Mail.WinUI/Dialogs/NewImapSetupDialog.xaml.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.Messaging; -using CommunityToolkit.WinUI; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media.Animation; -using Wino.Core.Domain.Entities.Shared; -using Wino.Core.Domain.Enums; -using Wino.Core.Domain.Interfaces; -using Wino.Core.Domain.Models.Accounts; -using Wino.Messaging.Client.Mails; -using Wino.Views.ImapSetup; - -namespace Wino.Dialogs; - -public enum ImapSetupState -{ - Welcome, - AutoDiscovery, - TestingConnection, - PreparingFolder -} - -public sealed partial class NewImapSetupDialog : ContentDialog, - IRecipient, - IRecipient, - IRecipient, - IImapAccountCreationDialog -{ - private TaskCompletionSource _getServerInfoTaskCompletionSource = new TaskCompletionSource(); - private TaskCompletionSource dialogOpened = new TaskCompletionSource(); - private bool isDismissRequested = false; - - public NewImapSetupDialog() - { - InitializeComponent(); - } - - // Not used for now. - public AccountCreationDialogState State { get; set; } - - public void Complete(bool cancel) - { - if (!_getServerInfoTaskCompletionSource.Task.IsCompleted) - _getServerInfoTaskCompletionSource.TrySetResult(null!); - - isDismissRequested = true; - - Hide(); - } - - public Task GetCustomServerInformationAsync() => _getServerInfoTaskCompletionSource.Task; - - public async void Receive(ImapSetupBackNavigationRequested message) - { - // Frame go back - if (message.PageType == null) - { - if (ImapFrame.CanGoBack) - { - // Go back using Dispatcher to allow navigations in OnNavigatedTo. - await DispatcherQueue.EnqueueAsync(() => - { - ImapFrame.GoBack(); - }); - } - } - else - { - ImapFrame.Navigate(message.PageType, message.Parameter, new SlideNavigationTransitionInfo() { Effect = SlideNavigationTransitionEffect.FromLeft }); - } - } - - public void Receive(ImapSetupNavigationRequested message) - { - ImapFrame.Navigate(message.PageType, message.Parameter, new SlideNavigationTransitionInfo() { Effect = SlideNavigationTransitionEffect.FromRight }); - } - - public void Receive(ImapSetupDismissRequested message) => _getServerInfoTaskCompletionSource.TrySetResult(message.CompletedServerInformation); - - public async Task ShowDialogAsync(CancellationTokenSource cancellationTokenSource) - { - Opened += DialogOpened; - - _ = ShowAsync(); - - await dialogOpened.Task; - } - - private void DialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args) - { - Opened -= DialogOpened; - - dialogOpened?.SetResult(true); - } - - public void ShowPreparingFolders() - { - ImapFrame.Navigate(typeof(PreparingImapFoldersPage), new SlideNavigationTransitionInfo() { Effect = SlideNavigationTransitionEffect.FromLeft }); - } - - public void StartImapConnectionSetup(MailAccount account) => ImapFrame.Navigate(typeof(WelcomeImapSetupPage), account, new DrillInNavigationTransitionInfo()); - public void StartImapConnectionSetup(AccountCreationDialogResult accountCreationDialogResult) => ImapFrame.Navigate(typeof(WelcomeImapSetupPage), accountCreationDialogResult, new DrillInNavigationTransitionInfo()); - - private void ImapSetupDialogClosed(ContentDialog sender, ContentDialogClosedEventArgs args) - { - WeakReferenceMessenger.Default.Unregister(this); - WeakReferenceMessenger.Default.Unregister(this); - WeakReferenceMessenger.Default.Unregister(this); - } - - private void ImapSetupDialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args) - { - WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); - } - - // Don't hide the dialog unless dismiss is requested from the inner pages specifically. - private void OnDialogClosing(ContentDialog sender, ContentDialogClosingEventArgs args) => args.Cancel = !isDismissRequested; -} diff --git a/Wino.Mail.WinUI/Services/DialogService.cs b/Wino.Mail.WinUI/Services/DialogService.cs index 07f2a361..2b342435 100644 --- a/Wino.Mail.WinUI/Services/DialogService.cs +++ b/Wino.Mail.WinUI/Services/DialogService.cs @@ -32,33 +32,6 @@ public class DialogService : DialogServiceBase, IMailDialogService } - public override IAccountCreationDialog GetAccountCreationDialog(AccountCreationDialogResult accountCreationDialogResult) - { - if (accountCreationDialogResult.SpecialImapProviderDetails == null) - { - if (accountCreationDialogResult.ProviderType == MailProviderType.IMAP4) - { - - return new NewImapSetupDialog - { - RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme(), - XamlRoot = GetXamlRoot() - }; - } - else - { - return base.GetAccountCreationDialog(accountCreationDialogResult); - } - } - else - { - // Special IMAP provider like iCloud or Yahoo. - - return base.GetAccountCreationDialog(accountCreationDialogResult); - } - } - - public async Task ShowCreateAccountAliasDialogAsync() { var createAccountAliasDialog = new CreateAccountAliasDialog() diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs index ecfc435d..2d6b00e0 100644 --- a/Wino.Mail.WinUI/Services/NavigationService.cs +++ b/Wino.Mail.WinUI/Services/NavigationService.cs @@ -63,6 +63,7 @@ public class NavigationService : NavigationServiceBase, INavigationService WinoPage.AliasManagementPage => typeof(AliasManagementPage), WinoPage.LanguageTimePage => typeof(LanguageTimePage), WinoPage.EditAccountDetailsPage => typeof(EditAccountDetailsPage), + WinoPage.ImapCalDavSettingsPage => typeof(ImapCalDavSettingsPage), WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage), WinoPage.ContactsPage => typeof(ContactsPage), WinoPage.SignatureAndEncryptionPage => typeof(SignatureAndEncryptionPage), diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml index 630d259d..bd138e12 100644 --- a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml +++ b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml @@ -35,7 +35,7 @@ - + diff --git a/Wino.Mail.WinUI/Views/Abstract/ImapCalDavSettingsPageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/ImapCalDavSettingsPageAbstract.cs new file mode 100644 index 00000000..768f50db --- /dev/null +++ b/Wino.Mail.WinUI/Views/Abstract/ImapCalDavSettingsPageAbstract.cs @@ -0,0 +1,8 @@ +using Wino.Mail.WinUI; +using Wino.Mail.ViewModels; + +namespace Wino.Views.Abstract; + +public abstract class ImapCalDavSettingsPageAbstract : BasePage +{ +} diff --git a/Wino.Mail.WinUI/Views/Account/ImapCalDavSettingsPage.xaml b/Wino.Mail.WinUI/Views/Account/ImapCalDavSettingsPage.xaml new file mode 100644 index 00000000..dfe716a2 --- /dev/null +++ b/Wino.Mail.WinUI/Views/Account/ImapCalDavSettingsPage.xaml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - -