CalDav synchronizer, new IMAP setup/edit page.

This commit is contained in:
Burak Kaan Köse
2026-02-15 02:20:18 +01:00
parent 64b9bfc392
commit acf0f649e8
58 changed files with 3993 additions and 1732 deletions
@@ -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; }
/// <summary>
/// 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 }
};
@@ -0,0 +1,8 @@
namespace Wino.Core.Domain.Enums;
public enum ImapCalendarSupportMode
{
Disabled = 0,
CalDav = 1,
LocalOnly = 2
}
+1
View File
@@ -27,6 +27,7 @@ public enum WinoPage
SettingOptionsPage,
AliasManagementPage,
EditAccountDetailsPage,
ImapCalDavSettingsPage,
KeyboardShortcutsPage,
CalendarPage,
CalendarSettingsPage,
@@ -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;
/// <summary>
/// Searches for Auto Discovery settings for custom mail accounts.
/// Searches for auto-discovery settings for custom mail accounts.
/// </summary>
public interface IAutoDiscoveryService
{
/// <summary>
/// Tries to return the best mail server settings using different techniques.
/// </summary>
/// <param name="mailAddress">Address to search settings for.</param>
/// <returns>CustomServerInformation with only settings applied.</returns>
Task<AutoDiscoverySettings> GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings);
/// <summary>
/// Tries to resolve a CalDAV endpoint for the mailbox address.
/// </summary>
Task<Uri> DiscoverCalDavServiceUriAsync(string mailAddress, CancellationToken cancellationToken = default);
}
@@ -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<IReadOnlyList<CalDavCalendar>> DiscoverCalendarsAsync(
CalDavConnectionSettings connectionSettings,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<CalDavCalendarEvent>> GetCalendarEventsAsync(
CalDavConnectionSettings connectionSettings,
CalDavCalendar calendar,
DateTimeOffset startUtc,
DateTimeOffset endUtc,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,14 @@
using System;
using System.Threading.Tasks;
namespace Wino.Core.Domain.Interfaces;
/// <summary>
/// Persists CalDAV ICS payloads on disk for IMAP accounts.
/// </summary>
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);
}
@@ -1,24 +0,0 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared;
namespace Wino.Core.Domain.Interfaces;
public interface IImapAccountCreationDialog : IAccountCreationDialog
{
/// <summary>
/// Returns the custom server information from the dialog..
/// </summary>
/// <returns>Null if canceled.</returns>
Task<CustomServerInformation> GetCustomServerInformationAsync();
/// <summary>
/// Displays preparing folders page.
/// </summary>
void ShowPreparingFolders();
/// <summary>
/// Updates account properties for the welcome imap setup dialog and starts the setup.
/// </summary>
/// <param name="account">Account properties.</param>
void StartImapConnectionSetup(MailAccount account);
}
@@ -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));
}
@@ -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;
}
@@ -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<CalDavEventAttendee> Attendees { get; init; } = [];
public IReadOnlyList<CalDavEventReminder> 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;
}
@@ -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;
}
@@ -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",
@@ -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;
/// </summary>
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<MailCopy>(),
Connection.CreateTableAsync<MailItemFolder>(),
Connection.CreateTableAsync<MailAccount>(),
Connection.CreateTableAsync<AccountContact>(),
Connection.CreateTableAsync<CustomServerInformation>(),
Connection.CreateTableAsync<AccountSignature>(),
Connection.CreateTableAsync<MergedInbox>(),
Connection.CreateTableAsync<MailAccountPreferences>(),
Connection.CreateTableAsync<MailAccountAlias>(),
Connection.CreateTableAsync<Thumbnail>(),
Connection.CreateTableAsync<KeyboardShortcut>(),
Connection.CreateTableAsync<AccountCalendar>(),
Connection.CreateTableAsync<CalendarEventAttendee>(),
Connection.CreateTableAsync<CalendarItem>(),
Connection.CreateTableAsync<Reminder>()
);
// Keep table creation sequential for in-memory SQLite to avoid connection contention.
await Connection.CreateTableAsync<MailCopy>();
await Connection.CreateTableAsync<MailItemFolder>();
await Connection.CreateTableAsync<MailAccount>();
await Connection.CreateTableAsync<AccountContact>();
await Connection.CreateTableAsync<CustomServerInformation>();
await Connection.CreateTableAsync<AccountSignature>();
await Connection.CreateTableAsync<MergedInbox>();
await Connection.CreateTableAsync<MailAccountPreferences>();
await Connection.CreateTableAsync<MailAccountAlias>();
await Connection.CreateTableAsync<Thumbnail>();
await Connection.CreateTableAsync<KeyboardShortcut>();
await Connection.CreateTableAsync<AccountCalendar>();
await Connection.CreateTableAsync<CalendarEventAttendee>();
await Connection.CreateTableAsync<CalendarItem>();
await Connection.CreateTableAsync<CalendarAttachment>();
await Connection.CreateTableAsync<Reminder>();
await Connection.CreateTableAsync<MailInvitationCalendarMapping>();
}
public async ValueTask DisposeAsync()
@@ -53,5 +57,10 @@ public class InMemoryDatabaseService : IDatabaseService
await Connection.CloseAsync();
Connection = null!;
}
if (File.Exists(_databasePath))
{
File.Delete(_databasePath);
}
}
}
@@ -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("""
<clientConfig version="1.1">
<emailProvider id="example.com">
<incomingServer type="imap">
<hostname>imap.example.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILLOCALPART%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.example.com</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig>
""", 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<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory = responseFactory;
public List<string> RequestedUris { get; } = [];
protected override Task<HttpResponseMessage> 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);
}
}
}
@@ -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()
{
@@ -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
};
}
@@ -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<ImapClientPoolException>();
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);
}
}
@@ -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<IAutoDiscoveryService>(MockBehavior.Strict);
var serverInformation = CreateServerInformation();
serverInformation.CalDavServiceUrl = "https://caldav.explicit.example.com/";
var synchronizer = CreateSynchronizer(tempDirectory, serverInformation, autoDiscovery.Object);
try
{
var resolvedUri = await InvokePrivateAsync<Uri>(synchronizer, "ResolveCalDavServiceUriAsync", CancellationToken.None);
resolvedUri.Should().Be(new Uri("https://caldav.explicit.example.com/"));
autoDiscovery.Verify(a => a.DiscoverCalDavServiceUriAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<string>(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<string>(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<IApplicationConfiguration>();
applicationConfiguration.SetupProperty(x => x.ApplicationDataFolderPath, appDataFolder);
applicationConfiguration.SetupProperty(x => x.PublisherSharedFolderPath, appDataFolder);
applicationConfiguration.SetupProperty(x => x.ApplicationTempFolderPath, appDataFolder);
applicationConfiguration.SetupGet(x => x.SentryDNS).Returns(string.Empty);
var unifiedSynchronizer = new UnifiedImapSynchronizer(
Mock.Of<IFolderService>(),
Mock.Of<IMailService>(),
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
return new ImapSynchronizer(
account,
Mock.Of<IImapChangeProcessor>(),
applicationConfiguration.Object,
unifiedSynchronizer,
Mock.Of<IImapSynchronizerErrorHandlerFactory>(),
Mock.Of<ICalDavClient>(),
autoDiscoveryService ?? Mock.Of<IAutoDiscoveryService>());
}
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<T>(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<T> InvokePrivateAsync<T>(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<T>)method.Invoke(instance, parameters)!;
return await task.ConfigureAwait(false);
}
}
@@ -74,6 +74,8 @@ public class ImapSynchronizerIdleTests
Mock.Of<IImapChangeProcessor>(),
applicationConfiguration.Object,
unifiedSynchronizer,
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
Mock.Of<IImapSynchronizerErrorHandlerFactory>(),
Mock.Of<ICalDavClient>(),
Mock.Of<IAutoDiscoveryService>());
}
}
+82 -19
View File
@@ -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
/// <summary>
/// Pre-warms the pool (legacy compatibility method).
/// </summary>
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);
}
}
/// <summary>
/// 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)
@@ -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
/// <param name="folderId">Folder ID.</param>
/// <param name="count">Number of recent mails to return.</param>
Task<IEnumerable<string>> 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<List<AccountCalendar>> 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<CalendarItem> GetCalendarItemAsync(Guid calendarId, string remoteEventId)
@@ -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<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId) => FolderService.GetKnownUidsForFolderAsync(folderId);
public Task<IEnumerable<string>> 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);
}
}
+667 -27
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<AutoDiscoveryService>();
private static readonly StringComparer IgnoreCase = StringComparer.OrdinalIgnoreCase;
private static readonly HttpMethod OptionsMethod = new("OPTIONS");
public Task<AutoDiscoverySettings> GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings)
=> GetSettingsFromFiretrustAsync(autoDiscoveryMinimalSettings.Email);
private readonly HttpClient _httpClient;
private readonly Dictionary<string, Uri> _calDavUriCache = new(IgnoreCase);
private readonly object _calDavCacheLock = new();
private static async Task<AutoDiscoverySettings> 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<AutoDiscoverySettings> DeserializeFiretrustResponse(HttpResponseMessage response)
public async Task<AutoDiscoverySettings> 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<Uri> 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<AutoDiscoverySettings> 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<AutoDiscoverySettings> 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<AutoDiscoverySettings> 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<AutoDiscoverySettings> 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<AutoDiscoverySettings> 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<AutoDiscoverySettings> 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<AutoDiscoveryProviderSetting> 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<string> 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<AutoDiscoverySettings> 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<AutoDiscoveryProviderSetting> 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<IReadOnlyList<string>> GetMxSearchDomainsAsync(string domain, CancellationToken cancellationToken)
{
var results = new List<string> { 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<string> GetFirstResolvableHostAsync(IEnumerable<string> 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<bool> 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<IReadOnlyList<string>> 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<string>();
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<string>();
}
var values = new List<string>();
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<string>();
}
}
private async Task<Uri> 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<Uri> 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<Uri> 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<string> 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);
}
+8 -2
View File
@@ -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<IWinoSynchronizerBase> 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<IWinoSynchronizerBase> 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;
}
+317 -3
View File
@@ -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<ImapRequest, ImapMessageCreatio
private readonly IApplicationConfiguration _applicationConfiguration;
private readonly UnifiedImapSynchronizer _unifiedSynchronizer;
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
private readonly ICalDavClient _calDavClient;
private readonly IAutoDiscoveryService _autoDiscoveryService;
private readonly SemaphoreSlim _calDavDiscoveryLock = new(1, 1);
private Uri _cachedCalDavServiceUri;
private bool _isCalDavDiscoveryAttempted;
public ImapSynchronizer(MailAccount account,
IImapChangeProcessor imapChangeProcessor,
IApplicationConfiguration applicationConfiguration,
UnifiedImapSynchronizer unifiedSynchronizer,
IImapSynchronizerErrorHandlerFactory errorHandlerFactory) : base(account, WeakReferenceMessenger.Default)
IImapSynchronizerErrorHandlerFactory errorHandlerFactory,
ICalDavClient calDavClient,
IAutoDiscoveryService autoDiscoveryService) : base(account, WeakReferenceMessenger.Default)
{
// Create client pool with account protocol log.
_imapChangeProcessor = imapChangeProcessor;
_applicationConfiguration = applicationConfiguration;
_unifiedSynchronizer = unifiedSynchronizer;
_errorHandlerFactory = errorHandlerFactory;
_calDavClient = calDavClient;
_autoDiscoveryService = autoDiscoveryService;
var protocolLogStream = CreateAccountProtocolLogFileStream();
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
@@ -978,8 +990,310 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
public bool ShouldUpdateFolder(IMailFolder remoteFolder, MailItemFolder localFolder)
=> !localFolder.FolderName.Equals(remoteFolder.Name, StringComparison.OrdinalIgnoreCase);
protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
protected override async Task<CalendarSynchronizationResult> 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<CalDavCalendar> 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<Uri> 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<CalDavCalendar> 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()
{
@@ -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()
@@ -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<ImapCalDavSetupResult>();
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<ImapConnectivityTestResults, ImapConnectivityTestRequested>(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)
@@ -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<ImapCalDavSetupResult> CompletionSource { get; init; }
public static ImapCalDavSettingsNavigationContext CreateForCreateMode(
AccountCreationDialogResult accountCreationDialogResult,
TaskCompletionSource<ImapCalDavSetupResult> 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;
}
@@ -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<ImapCalDavSetupResult> _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<ImapAuthenticationMethodModel> 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<ImapConnectionSecurityModel> 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<string> AvailableConnectionSecurityDisplayNames { get; } =
[
Translator.ImapConnectionSecurity_Auto,
Translator.ImapConnectionSecurity_SslTls,
Translator.ImapConnectionSecurity_StartTls,
Translator.ImapConnectionSecurity_None
];
public List<ImapCalendarSupportModeOption> 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<string> 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<string> 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;
}
}
+1
View File
@@ -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));
@@ -1,25 +0,0 @@
<ContentDialog
x:Class="Wino.Dialogs.NewImapSetupDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Wino.Dialogs"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Closed="ImapSetupDialogClosed"
Closing="OnDialogClosing"
DefaultButton="Secondary"
FullSizeDesired="False"
Opened="ImapSetupDialogOpened"
Style="{StaticResource WinoDialogStyle}"
mc:Ignorable="d">
<ContentDialog.Resources>
<Thickness x:Key="ContentDialogPadding">0,0,0,0</Thickness>
<!--<x:Double x:Key="ContentDialogMinWidth">768</x:Double>-->
<x:Double x:Key="ContentDialogMaxWidth">1920</x:Double>
<!--<x:Double x:Key="ContentDialogMinHeight">768</x:Double>
<x:Double x:Key="ContentDialogMaxHeight">2000</x:Double>-->
</ContentDialog.Resources>
<Frame x:Name="ImapFrame" />
</ContentDialog>
@@ -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<ImapSetupNavigationRequested>,
IRecipient<ImapSetupBackNavigationRequested>,
IRecipient<ImapSetupDismissRequested>,
IImapAccountCreationDialog
{
private TaskCompletionSource<CustomServerInformation> _getServerInfoTaskCompletionSource = new TaskCompletionSource<CustomServerInformation>();
private TaskCompletionSource<bool> dialogOpened = new TaskCompletionSource<bool>();
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<CustomServerInformation> 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<ImapSetupNavigationRequested>(this);
WeakReferenceMessenger.Default.Unregister<ImapSetupBackNavigationRequested>(this);
WeakReferenceMessenger.Default.Unregister<ImapSetupDismissRequested>(this);
}
private void ImapSetupDialogOpened(ContentDialog sender, ContentDialogOpenedEventArgs args)
{
WeakReferenceMessenger.Default.Register<ImapSetupNavigationRequested>(this);
WeakReferenceMessenger.Default.Register<ImapSetupBackNavigationRequested>(this);
WeakReferenceMessenger.Default.Register<ImapSetupDismissRequested>(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;
}
-27
View File
@@ -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<ICreateAccountAliasDialog> ShowCreateAccountAliasDialogAsync()
{
var createAccountAliasDialog = new CreateAccountAliasDialog()
@@ -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),
@@ -35,7 +35,7 @@
</DataTemplate>
<DataTemplate x:Key="BusyStripeTemplate" x:DataType="data:CalendarItemViewModel">
<Border Background="#F44336" />
<Border Background="#ff7675" />
</DataTemplate>
<DataTemplate x:Key="OutOfOfficeStripeTemplate" x:DataType="data:CalendarItemViewModel">
@@ -0,0 +1,8 @@
using Wino.Mail.WinUI;
using Wino.Mail.ViewModels;
namespace Wino.Views.Abstract;
public abstract class ImapCalDavSettingsPageAbstract : BasePage<ImapCalDavSettingsPageViewModel>
{
}
@@ -0,0 +1,171 @@
<abstract:ImapCalDavSettingsPageAbstract
x:Class="Wino.Views.ImapCalDavSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:abstract="using:Wino.Views.Abstract"
xmlns:helpers="using:Wino.Helpers"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer>
<StackPanel MaxWidth="1040" Padding="24,20,24,24" Spacing="16">
<StackPanel Spacing="4">
<TextBlock FontSize="30" FontWeight="SemiBold" Text="{x:Bind ViewModel.PageTitle, Mode=OneWay}" />
<TextBlock
Opacity="0.85"
Text="{x:Bind ViewModel.SubtitleText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock Opacity="0.85" Text="{x:Bind ViewModel.ProviderHint, Mode=OneWay}" TextWrapping="WrapWholeWords" />
</StackPanel>
<SelectorBar x:Name="SetupModeSelector" SelectionChanged="OnSetupModeSelectionChanged">
<SelectorBarItem Icon="Library" Text="{x:Bind ViewModel.BasicTabText, Mode=OneWay}" />
<SelectorBarItem Icon="Setting" Text="{x:Bind ViewModel.AdvancedTabText, Mode=OneWay}" />
</SelectorBar>
<Border
Padding="16"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="12"
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(ViewModel.IsAdvancedSetupSelected), Mode=OneWay}">
<StackPanel Spacing="12">
<TextBlock FontSize="19" FontWeight="SemiBold" Text="{x:Bind ViewModel.BasicSectionTitleText, Mode=OneWay}" />
<TextBlock Opacity="0.75" Text="{x:Bind ViewModel.BasicSectionDescriptionText, Mode=OneWay}" TextWrapping="WrapWholeWords" />
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Header="{x:Bind ViewModel.DisplayNameHeaderText, Mode=OneWay}" PlaceholderText="{x:Bind ViewModel.DisplayNamePlaceholderText, Mode=OneWay}" Text="{x:Bind ViewModel.DisplayName, Mode=TwoWay}" />
<TextBox Grid.Column="1" Header="{x:Bind ViewModel.EmailAddressHeaderText, Mode=OneWay}" PlaceholderText="{x:Bind ViewModel.EmailAddressPlaceholderText, Mode=OneWay}" Text="{x:Bind ViewModel.EmailAddress, Mode=TwoWay}" />
</Grid>
<PasswordBox Header="{x:Bind ViewModel.PasswordHeaderText, Mode=OneWay}" Password="{x:Bind ViewModel.Password, Mode=TwoWay}" />
<CheckBox Content="{x:Bind ViewModel.EnableCalendarSupportText, Mode=OneWay}" IsChecked="{x:Bind ViewModel.IsCalendarSupportEnabled, Mode=TwoWay}" />
<Button
HorizontalAlignment="Left"
Command="{x:Bind ViewModel.AutoDiscoverSettingsCommand}"
Content="{x:Bind ViewModel.AutoDiscoverButtonText, Mode=OneWay}" />
</StackPanel>
</Border>
<Border
Padding="16"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="12"
Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(ViewModel.IsBasicSetupSelected), Mode=OneWay}">
<StackPanel Spacing="14">
<TextBlock FontSize="19" FontWeight="SemiBold" Text="{x:Bind ViewModel.AdvancedSectionTitleText, Mode=OneWay}" />
<TextBlock
Opacity="0.75"
Text="{x:Bind ViewModel.AdvancedSectionDescriptionText, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<Grid ColumnSpacing="18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.IncomingSectionTitleText, Mode=OneWay}" />
<TextBox Header="{x:Bind ViewModel.IncomingServerHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.IncomingServer, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.PortHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.IncomingServerPort, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.IncomingUsernameHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.IncomingServerUsername, Mode=TwoWay}" />
<PasswordBox Header="{x:Bind ViewModel.IncomingPasswordHeaderText, Mode=OneWay}" Password="{x:Bind ViewModel.IncomingServerPassword, Mode=TwoWay}" />
<ComboBox
Header="{x:Bind ViewModel.ConnectionSecurityHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableConnectionSecurityDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedIncomingServerConnectionSecurityIndex, Mode=TwoWay}" />
<ComboBox
Header="{x:Bind ViewModel.AuthenticationMethodHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableAuthenticationMethodDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedIncomingServerAuthenticationMethodIndex, Mode=TwoWay}" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.OutgoingSectionTitleText, Mode=OneWay}" />
<TextBox Header="{x:Bind ViewModel.OutgoingServerHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.OutgoingServer, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.PortHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.OutgoingServerPort, Mode=TwoWay}" />
<TextBox Header="{x:Bind ViewModel.OutgoingUsernameHeaderText, Mode=OneWay}" Text="{x:Bind ViewModel.OutgoingServerUsername, Mode=TwoWay}" />
<PasswordBox Header="{x:Bind ViewModel.OutgoingPasswordHeaderText, Mode=OneWay}" Password="{x:Bind ViewModel.OutgoingServerPassword, Mode=TwoWay}" />
<ComboBox
Header="{x:Bind ViewModel.ConnectionSecurityHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableConnectionSecurityDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedOutgoingServerConnectionSecurityIndex, Mode=TwoWay}" />
<ComboBox
Header="{x:Bind ViewModel.AuthenticationMethodHeaderText, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableAuthenticationMethodDisplayNames}"
SelectedIndex="{x:Bind ViewModel.SelectedOutgoingServerAuthenticationMethodIndex, Mode=TwoWay}" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<Border
Padding="16"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="12">
<StackPanel Spacing="12">
<TextBlock FontSize="19" FontWeight="SemiBold" Text="{x:Bind ViewModel.CalendarSectionTitleText, Mode=OneWay}" />
<TextBlock Opacity="0.75" Text="{x:Bind ViewModel.CalendarSectionDescriptionText, Mode=OneWay}" TextWrapping="WrapWholeWords" />
<ComboBox
Header="{x:Bind ViewModel.CalendarModeHeaderText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsCalendarModeSelectionVisible, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableCalendarSupportModeTitles}"
SelectedIndex="{x:Bind ViewModel.SelectedCalendarSupportModeIndex, Mode=TwoWay}" />
<TextBlock Opacity="0.8" Text="{x:Bind ViewModel.SelectedCalendarSupportDescription, Mode=OneWay}" TextWrapping="WrapWholeWords" />
<Button
HorizontalAlignment="Left"
Command="{x:Bind ViewModel.ShowLocalCalendarExplanationCommand}"
Content="{x:Bind ViewModel.LocalCalendarLearnMoreText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsLocalCalendarModeSelected, Mode=OneWay}" />
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Header="{x:Bind ViewModel.CalDavServiceUrlHeaderText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}"
Text="{x:Bind ViewModel.CalDavServiceUrl, Mode=TwoWay}" />
<TextBox
Grid.Column="1"
Header="{x:Bind ViewModel.CalDavUsernameHeaderText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}"
Text="{x:Bind ViewModel.CalDavUsername, Mode=TwoWay}" />
</Grid>
<PasswordBox
Header="{x:Bind ViewModel.CalDavPasswordHeaderText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}"
Password="{x:Bind ViewModel.CalDavPassword, Mode=TwoWay}" />
</StackPanel>
</Border>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Command="{x:Bind ViewModel.TestImapConnectionCommand}" Content="{x:Bind ViewModel.TestImapButtonText, Mode=OneWay}" />
<Button
Grid.Column="1"
Command="{x:Bind ViewModel.TestCalDavConnectionCommand}"
Content="{x:Bind ViewModel.TestCalDavButtonText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.IsCalDavSettingsVisible, Mode=OneWay}" />
<Button Grid.Column="3" Command="{x:Bind ViewModel.CancelCommand}" Content="{x:Bind ViewModel.CancelButtonText, Mode=OneWay}" />
<Button Grid.Column="4" Command="{x:Bind ViewModel.SaveCommand}" Content="{x:Bind ViewModel.SaveButtonText, Mode=OneWay}" Style="{ThemeResource AccentButtonStyle}" />
</Grid>
</StackPanel>
</ScrollViewer>
</abstract:ImapCalDavSettingsPageAbstract>
@@ -0,0 +1,31 @@
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
using Wino.Views.Abstract;
namespace Wino.Views;
public sealed partial class ImapCalDavSettingsPage : ImapCalDavSettingsPageAbstract
{
public ImapCalDavSettingsPage()
{
InitializeComponent();
}
private void OnSetupModeSelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs e)
{
ViewModel.SelectedSetupTabIndex = SetupModeSelector.SelectedItem == null ? 0 : SetupModeSelector.Items.IndexOf(SetupModeSelector.SelectedItem);
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
var tabIndex = ViewModel.SelectedSetupTabIndex;
if (tabIndex < 0 || tabIndex >= SetupModeSelector.Items.Count)
{
tabIndex = 0;
}
SetupModeSelector.SelectedItem = SetupModeSelector.Items[tabIndex];
}
}
@@ -1,300 +0,0 @@
<Page
x:Class="Wino.Views.ImapSetup.AdvancedImapSetupPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:accounts="using:Wino.Core.Domain.Models.Accounts"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:helpers="using:Wino.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
d:Background="Black"
d:RequestedTheme="Dark"
mc:Ignorable="d">
<Grid RowSpacing="4">
<Grid Visibility="{x:Bind helpers:XamlHelpers.ReverseBoolToVisibilityConverter(HasValidationErrors), Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ScrollViewer x:Name="MainScrollviewer" Padding="{StaticResource ImapSetupDialogSubPagePadding}">
<StackPanel Padding="0,0,16,0" Spacing="12">
<TextBlock
Margin="1,0,0,0"
d:Text="Advanced IMAP / SMTP Configuration"
Style="{StaticResource TitleTextBlockStyle}"
Text="{x:Bind domain:Translator.IMAPSetupDialog_Title}" />
<TextBox
x:Name="AddressBox"
d:Header="Mail"
Header="{x:Bind domain:Translator.IMAPSetupDialog_MailAddress}"
PlaceholderText="{x:Bind domain:Translator.IMAPSetupDialog_MailAddressPlaceholder}" />
<TextBox
x:Name="DisplayNameBox"
d:Header="Display Name"
Header="{x:Bind domain:Translator.IMAPSetupDialog_DisplayName}"
PlaceholderText="{x:Bind domain:Translator.IMAPSetupDialog_DisplayNamePlaceholder}" />
<CheckBox Content="{x:Bind domain:Translator.IMAPSetupDialog_UseSameConfig}" IsChecked="{x:Bind UseSameCredentialsForSending, Mode=TwoWay}" />
<TabView
d:SelectedIndex="0"
CanReorderTabs="False"
IsAddTabButtonVisible="False"
TabWidthMode="Equal">
<TabViewItem Header="IMAP Settings" IsClosable="False">
<!-- IMAP -->
<StackPanel Padding="12" Spacing="10">
<!-- Server + Port -->
<Grid ColumnSpacing="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
x:Name="IncomingServerBox"
d:Header="Incoming Server"
Header="{x:Bind domain:Translator.IMAPSetupDialog_IncomingMailServer}"
PlaceholderText="eg. imap.gmail.com"
TextChanged="IncomingServerChanged" />
<TextBox
x:Name="IncomingServerPortBox"
Grid.Column="1"
d:Header="Port"
Header="{x:Bind domain:Translator.IMAPSetupDialog_IncomingMailServerPort}"
Text="993" />
</Grid>
<!-- Username + Password -->
<StackPanel Spacing="6">
<TextBox
x:Name="UsernameBox"
d:Header="Username"
Header="{x:Bind domain:Translator.IMAPSetupDialog_Username}"
PlaceholderText="{x:Bind domain:Translator.IMAPSetupDialog_UsernamePlaceholder}"
TextChanged="IncomingUsernameChanged" />
<PasswordBox
x:Name="PasswordBox"
d:Header="Password"
Header="{x:Bind domain:Translator.IMAPSetupDialog_Password}"
PasswordChanged="IncomingPasswordChanged" />
</StackPanel>
<!-- Security and Authentication -->
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Security -->
<StackPanel Spacing="6">
<TextBlock
HorizontalAlignment="Center"
d:Text="Connection security"
Text="{x:Bind domain:Translator.ImapAdvancedSetupDialog_ConnectionSecurity}" />
<ComboBox
x:Name="IncomingConnectionSecurity"
HorizontalAlignment="Stretch"
ItemsSource="{x:Bind AvailableConnectionSecurities}"
SelectedIndex="0">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="accounts:ImapConnectionSecurityModel">
<TextBlock Text="{x:Bind DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<!-- Authentication -->
<StackPanel Grid.Column="1" Spacing="6">
<TextBlock
HorizontalAlignment="Center"
d:Text="Authentication method"
Text="{x:Bind domain:Translator.ImapAdvancedSetupDialog_AuthenticationMethod}" />
<ComboBox
x:Name="IncomingAuthenticationMethod"
HorizontalAlignment="Stretch"
ItemsSource="{x:Bind AvailableAuthenticationMethods}"
SelectedIndex="0">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="accounts:ImapAuthenticationMethodModel">
<TextBlock Text="{x:Bind DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Grid>
</StackPanel>
</TabViewItem>
<TabViewItem Header="SMTP Settings" IsClosable="False">
<!-- SMTP -->
<StackPanel Padding="12" Spacing="10">
<!-- Server + Port -->
<Grid ColumnSpacing="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
x:Name="OutgoingServerBox"
d:Header="Outgoing Server"
Header="{x:Bind domain:Translator.IMAPSetupDialog_OutgoingMailServer}"
PlaceholderText="eg. smtp.gmail.com"
TextChanged="OutgoingServerChanged" />
<TextBox
x:Name="OutgoingServerPort"
Grid.Column="1"
d:Header="Port"
Header="{x:Bind domain:Translator.IMAPSetupDialog_OutgoingMailServerPort}"
Text="465" />
</Grid>
<!-- Username + Password -->
<StackPanel x:Name="OutgoingAuthenticationPanel" Spacing="6">
<TextBox
x:Name="OutgoingUsernameBox"
d:Header="UserName"
Header="{x:Bind domain:Translator.IMAPSetupDialog_OutgoingMailServerUsername}"
IsEnabled="{x:Bind helpers:XamlHelpers.ReverseBoolConverter(UseSameCredentialsForSending), Mode=OneWay}" />
<PasswordBox
x:Name="OutgoingPasswordBox"
d:Header="Password"
Header="{x:Bind domain:Translator.IMAPSetupDialog_OutgoingMailServerPassword}"
IsEnabled="{x:Bind helpers:XamlHelpers.ReverseBoolConverter(UseSameCredentialsForSending), Mode=OneWay}" />
</StackPanel>
<!-- Security and Authentication -->
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Security -->
<StackPanel Spacing="6">
<TextBlock HorizontalAlignment="Center" Text="{x:Bind domain:Translator.ImapAdvancedSetupDialog_ConnectionSecurity}" />
<ComboBox
x:Name="OutgoingConnectionSecurity"
HorizontalAlignment="Stretch"
ItemsSource="{x:Bind AvailableConnectionSecurities}"
SelectedIndex="0">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="accounts:ImapConnectionSecurityModel">
<TextBlock Text="{x:Bind DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<!-- Authentication -->
<StackPanel Grid.Column="1" Spacing="6">
<TextBlock HorizontalAlignment="Center" Text="{x:Bind domain:Translator.ImapAdvancedSetupDialog_AuthenticationMethod}" />
<ComboBox
x:Name="OutgoingAuthenticationMethod"
HorizontalAlignment="Stretch"
ItemsSource="{x:Bind AvailableAuthenticationMethods}"
SelectedIndex="0">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="accounts:ImapAuthenticationMethodModel">
<TextBlock Text="{x:Bind DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Grid>
</StackPanel>
</TabViewItem>
<TabViewItem Header="Proxy" IsClosable="False">
<!-- Proxy -->
<StackPanel Padding="12" Spacing="10">
<TextBlock Text="Define your optional proxy server for the connection if your mail server requires it. This is optional." />
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="ProxyServerBox" Header="Proxy server" />
<NumberBox
x:Name="ProxyServerPortBox"
Grid.Column="1"
Header="Port" />
</Grid>
</StackPanel>
</TabViewItem>
</TabView>
</StackPanel>
</ScrollViewer>
<!-- Buttons -->
<Grid
Grid.Row="1"
Padding="{StaticResource ImapSetupDialogSubPagePadding}"
VerticalAlignment="Bottom"
Background="{ThemeResource ContentDialogBackground}"
ColumnSpacing="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button
HorizontalAlignment="Stretch"
d:Content="Cancel"
Click="CancelClicked"
Content="{x:Bind domain:Translator.Buttons_Cancel}" />
<Button
Grid.Column="1"
HorizontalAlignment="Stretch"
d:Content="Sign In"
Click="SignInClicked"
Content="{x:Bind domain:Translator.Buttons_SignIn}"
Style="{ThemeResource AccentButtonStyle}" />
</Grid>
</Grid>
<!-- Validation errors -->
<Grid
Padding="12"
RowSpacing="12"
Visibility="{x:Bind HasValidationErrors, Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
HorizontalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind domain:Translator.IMAPAdvancedSetupDialog_ValidationErrorTitle}" />
<ItemsControl Grid.Row="1" ItemsSource="{x:Bind ValidationErrors, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock>
<Run Text="• " /><Run Text="{x:Bind}" />
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button
Grid.Row="2"
HorizontalAlignment="Center"
Click="ValidationsGoBackClicked"
Content="{x:Bind domain:Translator.Buttons_TryAgain}"
Style="{StaticResource AccentButtonStyle}" />
</Grid>
</Grid>
</Page>
@@ -1,282 +0,0 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.AutoDiscovery;
using Wino.Messaging.Client.Mails;
namespace Wino.Views.ImapSetup;
public sealed partial class AdvancedImapSetupPage : Page
{
public static readonly DependencyProperty UseSameCredentialsForSendingProperty = DependencyProperty.Register(nameof(UseSameCredentialsForSending), typeof(bool), typeof(AdvancedImapSetupPage), new PropertyMetadata(true, OnUseSameCredentialsForSendingChanged));
public static readonly DependencyProperty ValidationErrorsProperty = DependencyProperty.Register(nameof(ValidationErrors), typeof(List<string>), typeof(AdvancedImapSetupPage), new PropertyMetadata(new List<string>()));
public List<ImapAuthenticationMethodModel> AvailableAuthenticationMethods { get; } = new List<ImapAuthenticationMethodModel>()
{
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.Auto, Translator.ImapAuthenticationMethod_Auto),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.None, Translator.ImapAuthenticationMethod_None),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.NormalPassword, Translator.ImapAuthenticationMethod_Plain),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.EncryptedPassword, Translator.ImapAuthenticationMethod_EncryptedPassword),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.Ntlm, Translator.ImapAuthenticationMethod_Ntlm),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.CramMd5, Translator.ImapAuthenticationMethod_CramMD5),
new ImapAuthenticationMethodModel(Core.Domain.Enums.ImapAuthenticationMethod.DigestMd5, Translator.ImapAuthenticationMethod_DigestMD5)
};
public List<ImapConnectionSecurityModel> AvailableConnectionSecurities { get; set; } = new List<ImapConnectionSecurityModel>()
{
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.Auto, Translator.ImapConnectionSecurity_Auto),
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.SslTls, Translator.ImapConnectionSecurity_SslTls),
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.StartTls, Translator.ImapConnectionSecurity_StartTls),
new ImapConnectionSecurityModel(Core.Domain.Enums.ImapConnectionSecurity.None, Translator.ImapConnectionSecurity_None)
};
public bool UseSameCredentialsForSending
{
get { return (bool)GetValue(UseSameCredentialsForSendingProperty); }
set { SetValue(UseSameCredentialsForSendingProperty, value); }
}
public List<string> ValidationErrors
{
get { return (List<string>)GetValue(ValidationErrorsProperty); }
set { SetValue(ValidationErrorsProperty, value); }
}
[GeneratedDependencyProperty]
public partial bool HasValidationErrors { get; set; }
public AdvancedImapSetupPage()
{
InitializeComponent();
NavigationCacheMode = NavigationCacheMode.Enabled;
}
private static void OnUseSameCredentialsForSendingChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
{
if (obj is AdvancedImapSetupPage page)
{
page.UpdateOutgoingAuthenticationPanel();
}
}
private void UpdateOutgoingAuthenticationPanel()
{
if (UseSameCredentialsForSending)
{
OutgoingUsernameBox.Text = UsernameBox.Text;
OutgoingPasswordBox.Password = PasswordBox.Password;
}
else
{
OutgoingUsernameBox.Text = string.Empty;
OutgoingPasswordBox.Password = string.Empty;
}
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
// Don't override settings on back scenarios.
// User is trying to try again the same configuration.
if (e.NavigationMode == NavigationMode.Back) return;
// Connection is succesfull but error occurred.
// Imap and Smptp settings exists here at this point.
if (e.Parameter is AutoDiscoverySettings preDefinedSettings && preDefinedSettings.UserMinimalSettings != null)
{
// TODO: Auto discovery settings adjustments.
UsernameBox.Text = preDefinedSettings.UserMinimalSettings.Email;
AddressBox.Text = preDefinedSettings.UserMinimalSettings.Email;
DisplayNameBox.Text = preDefinedSettings.UserMinimalSettings.DisplayName;
PasswordBox.Password = preDefinedSettings.UserMinimalSettings.Password;
var serverInfo = preDefinedSettings.ToServerInformation();
IncomingServerBox.Text = serverInfo.IncomingServer;
IncomingServerPortBox.Text = serverInfo.IncomingServerPort;
OutgoingPasswordBox.Password = serverInfo.OutgoingServerPassword;
OutgoingServerPort.Text = serverInfo.OutgoingServerPort;
OutgoingUsernameBox.Text = serverInfo.OutgoingServerUsername;
UseSameCredentialsForSending = OutgoingUsernameBox.Text == UsernameBox.Text;
}
else if (e.Parameter is AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings)
{
// Auto discovery failed. Only minimal settings are passed.
UsernameBox.Text = autoDiscoveryMinimalSettings.Email;
AddressBox.Text = autoDiscoveryMinimalSettings.Email;
DisplayNameBox.Text = autoDiscoveryMinimalSettings.DisplayName;
PasswordBox.Password = autoDiscoveryMinimalSettings.Password;
}
}
private void CancelClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new ImapSetupDismissRequested(null));
private string GetServerWithoutPort(string server)
{
var splitted = server.Split(':');
if (splitted.Length > 1)
{
return splitted[0];
}
return server;
}
private void SignInClicked(object sender, RoutedEventArgs e)
{
var errors = new List<string>();
// Validate email and display name
if (string.IsNullOrWhiteSpace(AddressBox.Text))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationEmailRequired);
else if (!EmailValidation.EmailValidator.Validate(AddressBox.Text))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationEmailInvalid);
if (string.IsNullOrWhiteSpace(DisplayNameBox.Text))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationDisplayNameRequired);
// Validate incoming server details
if (string.IsNullOrWhiteSpace(IncomingServerBox.Text))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationIncomingServerRequired);
if (string.IsNullOrWhiteSpace(IncomingServerPortBox.Text))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationIncomingPortRequired);
else if (!int.TryParse(IncomingServerPortBox.Text, out int inPort) || inPort <= 0 || inPort > 65535)
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationIncomingPortInvalid);
// Validate outgoing server details
if (string.IsNullOrWhiteSpace(OutgoingServerBox.Text))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingServerRequired);
if (string.IsNullOrWhiteSpace(OutgoingServerPort.Text))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingPortRequired);
else if (!int.TryParse(OutgoingServerPort.Text, out int outPort) || outPort <= 0 || outPort > 65535)
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingPortInvalid);
// Validate authentication details
if (string.IsNullOrWhiteSpace(UsernameBox.Text))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationUsernameRequired);
if (string.IsNullOrWhiteSpace(PasswordBox.Password))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationPasswordRequired);
// Validate outgoing credentials if not using same as incoming
if (!UseSameCredentialsForSending)
{
if (string.IsNullOrWhiteSpace(OutgoingUsernameBox.Text))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingUsernameRequired);
if (string.IsNullOrWhiteSpace(OutgoingPasswordBox.Password))
errors.Add(Translator.IMAPAdvancedSetupDialog_ValidationOutgoingPasswordRequired);
}
// Show validation errors if any
HasValidationErrors = errors.Count > 0;
if (HasValidationErrors)
{
ValidationErrors = errors;
return;
}
var info = new CustomServerInformation()
{
IncomingServer = GetServerWithoutPort(IncomingServerBox.Text),
Id = Guid.NewGuid(),
IncomingServerPassword = PasswordBox.Password,
IncomingServerType = Core.Domain.Enums.CustomIncomingServerType.IMAP4,
IncomingServerUsername = UsernameBox.Text,
IncomingAuthenticationMethod = (IncomingAuthenticationMethod.SelectedItem as ImapAuthenticationMethodModel)!.ImapAuthenticationMethod,
IncomingServerSocketOption = (IncomingConnectionSecurity.SelectedItem as ImapConnectionSecurityModel)!.ImapConnectionSecurity,
IncomingServerPort = IncomingServerPortBox.Text,
OutgoingServer = GetServerWithoutPort(OutgoingServerBox.Text),
OutgoingServerPort = OutgoingServerPort.Text,
OutgoingServerPassword = OutgoingPasswordBox.Password,
OutgoingAuthenticationMethod = (OutgoingAuthenticationMethod.SelectedItem as ImapAuthenticationMethodModel)!.ImapAuthenticationMethod,
OutgoingServerSocketOption = (OutgoingConnectionSecurity.SelectedItem as ImapConnectionSecurityModel)!.ImapConnectionSecurity,
OutgoingServerUsername = OutgoingUsernameBox.Text,
ProxyServer = ProxyServerBox.Text,
ProxyServerPort = ProxyServerPortBox.Text,
Address = AddressBox.Text,
DisplayName = DisplayNameBox.Text,
MaxConcurrentClients = 5
};
if (UseSameCredentialsForSending)
{
info.OutgoingServerUsername = info.IncomingServerUsername;
info.OutgoingServerPassword = info.IncomingServerPassword;
}
else
{
info.OutgoingServerUsername = OutgoingUsernameBox.Text;
info.OutgoingServerPassword = OutgoingPasswordBox.Password;
}
WeakReferenceMessenger.Default.Send(new ImapSetupNavigationRequested(typeof(TestingImapConnectionPage), info));
}
private void IncomingServerChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox senderTextBox)
{
var splitted = senderTextBox.Text.Split(':');
if (splitted.Length > 1)
{
IncomingServerPortBox.Text = splitted[splitted.Length - 1];
}
}
}
private void OutgoingServerChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox senderTextBox)
{
var splitted = senderTextBox.Text.Split(':');
if (splitted.Length > 1)
{
OutgoingServerPort.Text = splitted[splitted.Length - 1];
}
}
}
private void IncomingUsernameChanged(object sender, TextChangedEventArgs e)
{
if (UseSameCredentialsForSending)
{
OutgoingUsernameBox.Text = UsernameBox.Text;
}
}
private void IncomingPasswordChanged(object sender, RoutedEventArgs e)
{
if (UseSameCredentialsForSending)
{
OutgoingPasswordBox.Password = PasswordBox.Password;
}
}
private void ValidationsGoBackClicked(object sender, RoutedEventArgs e)
{
ValidationErrors.Clear();
HasValidationErrors = false;
}
}
@@ -1,72 +0,0 @@
<Page
x:Class="Wino.Views.ImapSetup.ImapConnectionFailedPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:local="using:Wino.Views.ImapSetup"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Padding="24" RowSpacing="6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Style="{StaticResource SubtitleTextBlockStyle}" Text="{x:Bind domain:Translator.IMAPSetupDialog_ConnectionFailedTitle}" />
<TextBlock
x:Name="ConnectionFailedMessage"
Grid.Row="1"
Style="{StaticResource BodyTextBlockStyle}" />
<!-- Protocol Log Area -->
<Grid
x:Name="ProtocolLogGrid"
Grid.Row="2"
ColumnSpacing="12"
Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
x:Name="ProtocolLogHeader"
VerticalAlignment="Center"
Foreground="{ThemeResource InfoBarWarningSeverityIconBackground}"
Text="{x:Bind domain:Translator.ProtocolLogAvailable_Message}" />
<Button
Grid.Column="1"
Click="CopyProtocolLogButtonClicked"
Content="{x:Bind domain:Translator.Buttons_Copy}" />
</Grid>
<!-- Dismis / GoBack -->
<Grid
Grid.Row="3"
VerticalAlignment="Bottom"
ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button
HorizontalAlignment="Stretch"
Click="CloseClicked"
Content="{x:Bind domain:Translator.Buttons_Close}" />
<Button
Grid.Column="1"
HorizontalAlignment="Stretch"
Click="TryAgainClicked"
Content="{x:Bind domain:Translator.Buttons_TryAgain}"
Style="{ThemeResource AccentButtonStyle}" />
</Grid>
</Grid>
</Page>
@@ -1,49 +0,0 @@
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
using Wino.Core.Domain;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Mail.WinUI;
using Wino.Messaging.Client.Mails;
namespace Wino.Views.ImapSetup;
public sealed partial class ImapConnectionFailedPage : Page
{
private string? _protocolLog;
private readonly IClipboardService _clipboardService = App.Current.Services.GetService<IClipboardService>()!;
private readonly IMailDialogService _dialogService = App.Current.Services.GetService<IMailDialogService>()!;
public ImapConnectionFailedPage()
{
InitializeComponent();
}
private async void CopyProtocolLogButtonClicked(object sender, RoutedEventArgs e)
{
await _clipboardService.CopyClipboardAsync(_protocolLog);
_dialogService.InfoBarMessage(Translator.ClipboardTextCopied_Title, string.Format(Translator.ClipboardTextCopied_Message, "Log"), Core.Domain.Enums.InfoBarMessageType.Information);
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
if (e.Parameter is ImapConnectionFailedPackage failedPackage)
{
ConnectionFailedMessage.Text = failedPackage.ErrorMessage;
ProtocolLogGrid.Visibility = !string.IsNullOrEmpty(failedPackage.ProtocolLog) ? Visibility.Visible : Visibility.Collapsed;
_protocolLog = failedPackage.ProtocolLog;
}
}
private void TryAgainClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new ImapSetupBackNavigationRequested());
private void CloseClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new ImapSetupDismissRequested());
}
@@ -1,31 +0,0 @@
<Page
x:Class="Wino.Views.ImapSetup.PreparingImapFoldersPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:local="using:Wino.Views.ImapSetup"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
mc:Ignorable="d">
<Grid>
<!-- Preparing Folders Panel -->
<StackPanel
x:Name="PreparingFoldersPanel"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Viewbox
Width="26"
Height="26"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<PathIcon Data="M1024,317.5L1024,507C1014.67,495.333 1004.67,484.167 994,473.5C983.333,462.833 972,453 960,444L960,320C960,311.333 958.333,303.083 955,295.25C951.667,287.417 947.083,280.583 941.25,274.75C935.417,268.917 928.583,264.333 920.75,261C912.917,257.667 904.667,256 896,256L522,256L458,298.5C436,312.833 412.333,320 387,320L64,320L64,832C64,841 65.6667,849.417 69,857.25C72.3333,865.083 76.8333,871.833 82.5,877.5C88.1667,883.167 94.9167,887.667 102.75,891C110.583,894.333 119,896 128,896L404.5,896C410.167,907.333 416.25,918.333 422.75,929C429.25,939.667 436.333,950 444,960L125.5,960C108.833,960 92.9167,956.583 77.75,949.75C62.5833,942.917 49.25,933.75 37.75,922.25C26.25,910.75 17.0833,897.417 10.25,882.25C3.41667,867.083 0,851.167 0,834.5L0,189.5C0,172.833 3.41667,156.917 10.25,141.75C17.0833,126.583 26.25,113.25 37.75,101.75C49.25,90.25 62.5833,81.0834 77.75,74.25C92.9167,67.4167 108.833,64.0001 125.5,64L368,64C388,64.0001 407.167,68.5001 425.5,77.5C443.833,86.5001 458.833,99.0001 470.5,115L528,192L898.5,192C915.167,192 931.083,195.417 946.25,202.25C961.417,209.083 974.75,218.25 986.25,229.75C997.75,241.25 1006.92,254.583 1013.75,269.75C1020.58,284.917 1024,300.833 1024,317.5ZM466,216L419,153.5C413,145.5 405.5,139.25 396.5,134.75C387.5,130.25 378,128 368,128L128,128C119,128 110.667,129.667 103,133C95.3333,136.333 88.5833,140.917 82.75,146.75C76.9167,152.583 72.3333,159.333 69,167C65.6667,174.667 64,183 64,192L64,256L387,256C394.333,256 401.5,254.667 408.5,252C415.5,249.333 422.25,246 428.75,242C435.25,238 441.583,233.667 447.75,229C453.917,224.333 460,220 466,216ZM1024,736C1024,775.667 1016.42,813 1001.25,848C986.083,883 965.5,913.5 939.5,939.5C913.5,965.5 883,986.083 848,1001.25C813,1016.42 775.667,1024 736,1024C696,1024 658.5,1016.5 623.5,1001.5C588.5,986.5 558,966 532,940C506,914 485.5,883.5 470.5,848.5C455.5,813.5 448,776 448,736C448,696.333 455.583,659 470.75,624C485.917,589 506.5,558.5 532.5,532.5C558.5,506.5 589,485.917 624,470.75C659,455.583 696.333,448 736,448C762.333,448 787.75,451.417 812.25,458.25C836.75,465.083 859.667,474.75 881,487.25C902.333,499.75 921.833,514.833 939.5,532.5C957.167,550.167 972.25,569.667 984.75,591C997.25,612.333 1006.92,635.25 1013.75,659.75C1020.58,684.25 1024,709.667 1024,736ZM896,576C896,567.333 892.833,559.833 886.5,553.5C880.167,547.167 872.667,544 864,544C857.667,544 852.5,545.167 848.5,547.5C844.5,549.833 841.25,552.917 838.75,556.75C836.25,560.583 834.5,565 833.5,570C832.5,575 832,580.167 832,585.5C816.333,577.167 800.917,570.833 785.75,566.5C770.583,562.167 754,560 736,560C724,560 711.75,561.25 699.25,563.75C686.75,566.25 674.5,569.917 662.5,574.75C650.5,579.583 639.083,585.417 628.25,592.25C617.417,599.083 608,607 600,616C597,619.333 594.667,622.75 593,626.25C591.333,629.75 590.5,633.833 590.5,638.5C590.5,647.5 593.667,655.167 600,661.5C606.333,667.833 614,671 623,671C628.667,671 634.75,668.583 641.25,663.75C647.75,658.917 655.333,653.5 664,647.5C672.667,641.5 682.75,636.083 694.25,631.25C705.75,626.417 719.333,624 735,624C746.667,624 757.5,625.25 767.5,627.75C777.5,630.25 787.667,634.333 798,640L785,640C779,640 773.083,640.25 767.25,640.75C761.417,641.25 756.167,642.583 751.5,644.75C746.833,646.917 743.083,650.167 740.25,654.5C737.417,658.833 736,664.667 736,672C736,680.667 739.167,688.167 745.5,694.5C751.833,700.833 759.333,704 768,704L864,704C872.667,704 880.167,700.833 886.5,694.5C892.833,688.167 896,680.667 896,672ZM881.5,833C881.5,824.333 878.333,816.833 872,810.5C865.667,804.167 858.167,801 849.5,801C842.833,801 836.333,803.417 830,808.25C823.667,813.083 816.333,818.5 808,824.5C799.667,830.5 789.833,835.917 778.5,840.75C767.167,845.583 753.333,848 737,848C725.333,848 714.5,846.75 704.5,844.25C694.5,841.75 684.333,837.667 674,832L687,832C692.667,832 698.417,831.75 704.25,831.25C710.083,830.75 715.333,829.417 720,827.25C724.667,825.083 728.5,821.833 731.5,817.5C734.5,813.167 736,807.333 736,800C736,791.333 732.833,783.833 726.5,777.5C720.167,771.167 712.667,768 704,768L608,768C599.333,768 591.833,771.167 585.5,777.5C579.167,783.833 576,791.333 576,800L576,896C576,904.667 579.167,912.167 585.5,918.5C591.833,924.833 599.333,928 608,928C614.333,928 619.5,926.833 623.5,924.5C627.5,922.167 630.75,919.083 633.25,915.25C635.75,911.417 637.5,907 638.5,902C639.5,897 640,891.833 640,886.5C655.667,894.833 671.083,901.167 686.25,905.5C701.417,909.833 718,912 736,912C748,912 760.333,910.75 773,908.25C785.667,905.75 797.917,902.083 809.75,897.25C821.583,892.417 832.833,886.583 843.5,879.75C854.167,872.917 863.667,865 872,856C878.333,849.333 881.5,841.667 881.5,833Z" />
</Viewbox>
<TextBlock Text="{x:Bind domain:Translator.PreparingFoldersMessage}" />
<ProgressBar Margin="0,4,0,0" IsIndeterminate="True" />
</StackPanel>
</Grid>
</Page>
@@ -1,29 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Foundation.Collections;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
// The Blank Page item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238
namespace Wino.Views.ImapSetup;
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class PreparingImapFoldersPage : Page
{
public PreparingImapFoldersPage()
{
this.InitializeComponent();
}
}
@@ -1,104 +0,0 @@
<Page
x:Class="Wino.Views.ImapSetup.TestingImapConnectionPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Wino.Controls"
xmlns:controls1="using:Wino.Mail.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:local="using:Wino.Views.ImapSetup"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
mc:Ignorable="d">
<Grid>
<!-- Testing Connection Panel -->
<StackPanel
x:Name="TestingConnectionPanel"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Viewbox
Width="26"
Height="26"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<PathIcon Data="F1 M 18.75 18.125 C 18.75 18.294271 18.68815 18.440756 18.564453 18.564453 C 18.440754 18.68815 18.29427 18.75 18.125 18.75 L 13.75 18.75 C 13.75 18.925781 13.717447 19.088541 13.652344 19.238281 C 13.587239 19.388021 13.497721 19.519857 13.383789 19.633789 C 13.269855 19.747721 13.13802 19.83724 12.988281 19.902344 C 12.838541 19.967447 12.675781 20 12.5 20 L 6.25 20 C 6.074219 20 5.909831 19.967447 5.756836 19.902344 C 5.603841 19.83724 5.472005 19.74935 5.361328 19.638672 C 5.250651 19.527994 5.16276 19.396158 5.097656 19.243164 C 5.032552 19.09017 5 18.925781 5 18.75 L 0.625 18.75 C 0.455729 18.75 0.309245 18.68815 0.185547 18.564453 C 0.061849 18.440756 0 18.294271 0 18.125 C 0 17.955729 0.061849 17.809244 0.185547 17.685547 C 0.309245 17.56185 0.455729 17.5 0.625 17.5 L 5 17.5 C 5 17.154949 5.120442 16.86198 5.361328 16.621094 C 5.608724 16.373699 5.904948 16.25 6.25 16.25 L 7.5 16.25 C 7.5 16.074219 7.532552 15.911459 7.597656 15.761719 C 7.66276 15.611979 7.752278 15.480144 7.866211 15.366211 C 7.980143 15.252279 8.111979 15.162761 8.261719 15.097656 C 8.411458 15.032553 8.574219 15 8.75 15 L 8.75 13.75 L 6.875 13.75 C 6.621094 13.75 6.380208 13.701172 6.152344 13.603516 C 5.924479 13.505859 5.724284 13.370769 5.551758 13.198242 C 5.379231 13.025717 5.244141 12.825521 5.146484 12.597656 C 5.048828 12.369792 5 12.128906 5 11.875 L 5 1.875 C 5 1.621094 5.048828 1.380209 5.146484 1.152344 C 5.244141 0.92448 5.379231 0.724285 5.551758 0.551758 C 5.724284 0.379232 5.924479 0.244141 6.152344 0.146484 C 6.380208 0.048828 6.621094 0 6.875 0 L 11.875 0 C 12.128906 0 12.369791 0.048828 12.597656 0.146484 C 12.825521 0.244141 13.025716 0.379232 13.198242 0.551758 C 13.370768 0.724285 13.505859 0.92448 13.603516 1.152344 C 13.701172 1.380209 13.75 1.621094 13.75 1.875 L 13.75 11.875 C 13.75 12.128906 13.701172 12.369792 13.603516 12.597656 C 13.505859 12.825521 13.370768 13.025717 13.198242 13.198242 C 13.025716 13.370769 12.825521 13.505859 12.597656 13.603516 C 12.369791 13.701172 12.128906 13.75 11.875 13.75 L 10 13.75 L 10 15 C 10.169271 15 10.330403 15.032553 10.483398 15.097656 C 10.636393 15.162761 10.769856 15.252279 10.883789 15.366211 C 10.997721 15.480144 11.087239 15.613607 11.152344 15.766602 C 11.217447 15.919597 11.25 16.080729 11.25 16.25 L 12.5 16.25 C 12.669271 16.25 12.828775 16.282553 12.978516 16.347656 C 13.128255 16.41276 13.261719 16.503906 13.378906 16.621094 C 13.626302 16.86849 13.75 17.161459 13.75 17.5 L 18.125 17.5 C 18.29427 17.5 18.440754 17.56185 18.564453 17.685547 C 18.68815 17.809244 18.75 17.955729 18.75 18.125 Z M 11.875 12.5 C 12.04427 12.5 12.190754 12.438151 12.314453 12.314453 C 12.43815 12.190756 12.5 12.044271 12.5 11.875 L 12.5 1.875 C 12.5 1.70573 12.43815 1.559246 12.314453 1.435547 C 12.190754 1.31185 12.04427 1.25 11.875 1.25 L 6.875 1.25 C 6.705729 1.25 6.559244 1.31185 6.435547 1.435547 C 6.311849 1.559246 6.25 1.70573 6.25 1.875 L 6.25 11.875 C 6.25 12.044271 6.311849 12.190756 6.435547 12.314453 C 6.559244 12.438151 6.705729 12.5 6.875 12.5 Z M 10.625 2.5 C 10.794271 2.5 10.940755 2.56185 11.064453 2.685547 C 11.18815 2.809246 11.25 2.95573 11.25 3.125 C 11.25 3.294271 11.18815 3.440756 11.064453 3.564453 C 10.940755 3.688152 10.794271 3.75 10.625 3.75 L 8.125 3.75 C 7.955729 3.75 7.809245 3.688152 7.685547 3.564453 C 7.561849 3.440756 7.5 3.294271 7.5 3.125 C 7.5 2.95573 7.561849 2.809246 7.685547 2.685547 C 7.809245 2.56185 7.955729 2.5 8.125 2.5 Z M 10.625 5 C 10.794271 5.000001 10.940755 5.06185 11.064453 5.185547 C 11.18815 5.309245 11.25 5.455729 11.25 5.625 C 11.25 5.794271 11.18815 5.940756 11.064453 6.064453 C 10.940755 6.188151 10.794271 6.25 10.625 6.25 L 8.125 6.25 C 7.955729 6.25 7.809245 6.188151 7.685547 6.064453 C 7.561849 5.940756 7.5 5.794271 7.5 5.625 C 7.5 5.455729 7.561849 5.309245 7.685547 5.185547 C 7.809245 5.06185 7.955729 5.000001 8.125 5 Z M 12.5 18.75 L 12.5 17.5 L 10.625 17.5 C 10.481771 17.5 10.367838 17.47233 10.283203 17.416992 C 10.198567 17.361654 10.135091 17.290039 10.092773 17.202148 C 10.050455 17.114258 10.022786 17.016602 10.009766 16.90918 C 9.996744 16.801758 9.990234 16.692709 9.990234 16.582031 C 9.990234 16.523438 9.991861 16.466471 9.995117 16.411133 C 9.998372 16.355795 10 16.302084 10 16.25 L 8.75 16.25 L 8.75 16.582031 C 8.75 16.692709 8.743489 16.801758 8.730469 16.90918 C 8.717447 17.016602 8.689778 17.114258 8.647461 17.202148 C 8.605143 17.290039 8.543294 17.361654 8.461914 17.416992 C 8.380533 17.47233 8.268229 17.5 8.125 17.5 L 6.25 17.5 L 6.25 18.75 Z " />
</Viewbox>
<TextBlock Text="{x:Bind domain:Translator.TestingImapConnectionMessage}" />
<ProgressBar Margin="0,4,0,0" IsIndeterminate="True" />
</StackPanel>
<!-- Allow untrusted certificate dialog -->
<Grid
x:Name="CertificateDialog"
MaxWidth="600"
Padding="20"
RowSpacing="12"
Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<controls1:WinoFontIcon FontSize="46" Icon="Certificate" />
<TextBlock
Grid.Row="1"
Margin="0,16,0,6"
TextWrapping="WrapWholeWords">
<Run Text="{x:Bind domain:Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row0}" />
<LineBreak />
<Run Text="{x:Bind domain:Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row1}" />
</TextBlock>
<!-- Cert details -->
<StackPanel Grid.Row="2" Spacing="6">
<TextBlock TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text="{x:Bind domain:Translator.IMAPSetupDialog_CertificateIssuer}" />
<LineBreak />
<Run x:Name="CertIssuer" />
</TextBlock>
<TextBlock>
<Run FontWeight="SemiBold" Text="{x:Bind domain:Translator.IMAPSetupDialog_CertificateValidFrom}" />
<LineBreak />
<Run x:Name="CertValidFrom" />
</TextBlock>
<TextBlock>
<Run FontWeight="SemiBold" Text="{x:Bind domain:Translator.IMAPSetupDialog_CertificateValidTo}" />
<LineBreak />
<Run x:Name="CertValidTo" />
</TextBlock>
</StackPanel>
<Grid
Grid.Row="3"
Margin="0,16"
ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button
x:Name="DenyCertificateButton"
HorizontalAlignment="Stretch"
Click="DenyClicked"
Content="{x:Bind domain:Translator.Buttons_Deny}" />
<Button
x:Name="AllowCertificateButton"
Grid.Column="1"
HorizontalAlignment="Stretch"
Click="AllowClicked"
Content="{x:Bind domain:Translator.Buttons_Allow}"
Style="{StaticResource AccentButtonStyle}" />
</Grid>
</Grid>
</Grid>
</Page>
@@ -1,116 +0,0 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Models.AutoDiscovery;
using Wino.Core.Services;
using Wino.Messaging.Client.Mails;
namespace Wino.Views.ImapSetup;
public sealed partial class TestingImapConnectionPage : Page
{
private AutoDiscoverySettings autoDiscoverySettings = null!;
private CustomServerInformation serverInformationToTest = null!;
public TestingImapConnectionPage()
{
InitializeComponent();
}
protected override async void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
// We can only go back to this page from failed connection page.
// We must go back once again in that case to actual setup dialog.
if (e.NavigationMode == NavigationMode.Back)
{
WeakReferenceMessenger.Default.Send(new ImapSetupBackNavigationRequested());
}
else
{
// Test connection
// Discovery settings are passed.
// Create server information out of the discovery settings.
if (e.Parameter is AutoDiscoverySettings parameterAutoDiscoverySettings)
{
autoDiscoverySettings = parameterAutoDiscoverySettings;
serverInformationToTest = autoDiscoverySettings.ToServerInformation();
}
else if (e.Parameter is CustomServerInformation customServerInformation)
{
// Only server information is passed.
serverInformationToTest = customServerInformation;
}
// Make sure that certificate dialog must be present in case of SSL handshake fails.
await PerformTestAsync(allowSSLHandshake: false);
}
}
private async Task PerformTestAsync(bool allowSSLHandshake)
{
CertificateDialog.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed;
TestingConnectionPanel.Visibility = Microsoft.UI.Xaml.Visibility.Visible;
await Task.Delay(1000);
var testResultData = await SynchronizationManager.Instance.TestImapConnectivityAsync(serverInformationToTest, allowSSLHandshake);
if (testResultData.IsSuccess)
{
// All success. Finish setup with validated server information.
ReturnWithSuccess();
}
else
{
// Check if certificate UI is required.
if (testResultData.IsCertificateUIRequired)
{
// Certificate UI is required. Show certificate dialog.
CertIssuer.Text = testResultData.CertificateIssuer;
CertValidFrom.Text = testResultData.CertificateValidFromDateString;
CertValidTo.Text = testResultData.CertificateExpirationDateString;
TestingConnectionPanel.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed;
CertificateDialog.Visibility = Microsoft.UI.Xaml.Visibility.Visible;
}
else
{
// Connection test failed. Show error dialog.
var protocolLog = testResultData.FailureProtocolLog;
ReturnWithError(testResultData.FailedReason, protocolLog);
}
}
}
private void ReturnWithError(string error, string protocolLog = "")
{
var failurePackage = new ImapConnectionFailedPackage(error, protocolLog, autoDiscoverySettings);
WeakReferenceMessenger.Default.Send(new ImapSetupBackNavigationRequested(typeof(ImapConnectionFailedPage), failurePackage));
}
private void ReturnWithSuccess()
=> WeakReferenceMessenger.Default.Send(new ImapSetupDismissRequested(serverInformationToTest));
private void DenyClicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
=> ReturnWithError(Translator.IMAPSetupDialog_CertificateDenied, string.Empty);
private async void AllowClicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
// Run the test again, but this time allow SSL handshake.
// Any authentication error will be shown to the user after this test.
await PerformTestAsync(allowSSLHandshake: true);
}
}
File diff suppressed because one or more lines are too long
@@ -1,96 +0,0 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.AutoDiscovery;
using Wino.Mail.WinUI;
using Wino.Messaging.Client.Mails;
namespace Wino.Views.ImapSetup;
public sealed partial class WelcomeImapSetupPage : Page
{
private readonly IAutoDiscoveryService _autoDiscoveryService = App.Current.Services.GetService<IAutoDiscoveryService>()!;
public WelcomeImapSetupPage()
{
InitializeComponent();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
AutoDiscoveryPanel.Visibility = Visibility.Collapsed;
MainSetupPanel.Visibility = Visibility.Visible;
if (e.Parameter is MailAccount accountProperties)
{
DisplayNameBox.Text = accountProperties.Name;
}
else if (e.Parameter is AccountCreationDialogResult creationDialogResult)
{
WeakReferenceMessenger.Default.Send(new ImapSetupNavigationRequested(typeof(TestingImapConnectionPage), creationDialogResult));
}
}
private async void SignInClicked(object sender, RoutedEventArgs e)
{
MainSetupPanel.Visibility = Visibility.Collapsed;
AutoDiscoveryPanel.Visibility = Visibility.Visible;
// Let users see the discovery message for a while...
await Task.Delay(1000);
var minimalSettings = new AutoDiscoveryMinimalSettings()
{
Password = PasswordBox.Password,
DisplayName = DisplayNameBox.Text,
Email = AddressBox.Text,
};
var discoverySettings = await _autoDiscoveryService.GetAutoDiscoverySettings(minimalSettings);
if (discoverySettings == null)
{
// Couldn't find settings.
var failurePackage = new ImapConnectionFailedPackage(Translator.Exception_ImapAutoDiscoveryFailed, string.Empty, discoverySettings);
WeakReferenceMessenger.Default.Send(new ImapSetupBackNavigationRequested(typeof(ImapConnectionFailedPage), failurePackage));
}
else
{
// Settings are found. Test the connection with the given password.
discoverySettings.UserMinimalSettings = minimalSettings;
WeakReferenceMessenger.Default.Send(new ImapSetupNavigationRequested(typeof(TestingImapConnectionPage), discoverySettings));
}
}
private void CancelClicked(object sender, RoutedEventArgs e) => WeakReferenceMessenger.Default.Send(new ImapSetupDismissRequested());
private void AdvancedConfigurationClicked(object sender, RoutedEventArgs e)
{
var latestMinimalSettings = new AutoDiscoveryMinimalSettings()
{
DisplayName = DisplayNameBox.Text,
Password = PasswordBox.Password,
Email = AddressBox.Text
};
WeakReferenceMessenger.Default.Send(new ImapSetupNavigationRequested(typeof(AdvancedImapSetupPage), latestMinimalSettings));
}
}
@@ -10,7 +10,6 @@
xmlns:data="using:Wino.Core.ViewModels.Data"
xmlns:domain="using:Wino.Core.Domain"
xmlns:helpers="using:Wino.Helpers"
xmlns:imapsetup="using:Wino.Views.ImapSetup"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:muxc="using:Microsoft.UI.Xaml.Controls"
mc:Ignorable="d">
+3 -18
View File
@@ -237,9 +237,6 @@
<Page Update="Dialogs\MoveMailDialog.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Dialogs\NewImapSetupDialog.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Dialogs\SignatureEditorDialog.xaml">
<SubType>Designer</SubType>
</Page>
@@ -264,6 +261,9 @@
<Page Update="Views\Account\AccountManagementPage.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\Account\ImapCalDavSettingsPage.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\Account\MergedAccountDetailsPage.xaml">
<SubType>Designer</SubType>
</Page>
@@ -273,21 +273,6 @@
<Page Update="Views\IdlePage.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\ImapSetup\AdvancedImapSetupPage.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\ImapSetup\ImapConnectionFailedPage.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\ImapSetup\PreparingImapFoldersPage.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\ImapSetup\TestingImapConnectionPage.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\ImapSetup\WelcomeImapSetupPage.xaml">
<SubType>Designer</SubType>
</Page>
<Page Update="Views\MailListPage.xaml">
<SubType>Designer</SubType>
</Page>
@@ -1,11 +0,0 @@
using System;
namespace Wino.Messaging.Client.Mails;
/// <summary>
/// When IMAP setup dialog requestes back breadcrumb navigation.
/// Not providing PageType will go back to previous page by doing back navigation.
/// </summary>
/// <param name="PageType">Type to go back.</param>
/// <param name="Parameter">Back parameters.</param>
public record ImapSetupBackNavigationRequested(Type PageType = null, object Parameter = null);
@@ -1,9 +0,0 @@
using Wino.Core.Domain.Entities.Shared;
namespace Wino.Messaging.Client.Mails;
/// <summary>
/// When user asked to dismiss IMAP setup dialog.
/// </summary>
/// <param name="CompletedServerInformation"> Validated server information that is ready to be saved to database. </param>
public record ImapSetupDismissRequested(CustomServerInformation CompletedServerInformation = null);
@@ -1,10 +0,0 @@
using System;
namespace Wino.Messaging.Client.Mails;
/// <summary>
/// When IMAP setup dialog breadcrumb navigation requested.
/// </summary>
/// <param name="PageType">Page type to navigate.</param>
/// <param name="Parameter">Navigation parameters.</param>
public record ImapSetupNavigationRequested(Type PageType, object Parameter);
+676
View File
@@ -0,0 +1,676 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using Serilog;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
namespace Wino.Services;
public sealed class CalDavClient : ICalDavClient
{
private static readonly HttpMethod PropFindMethod = new("PROPFIND");
private static readonly HttpMethod ReportMethod = new("REPORT");
private static readonly ILogger Logger = Log.ForContext<CalDavClient>();
private readonly HttpClient _httpClient;
public CalDavClient(HttpClient httpClient = null)
{
_httpClient = httpClient ?? new HttpClient();
}
public async Task<IReadOnlyList<CalDavCalendar>> DiscoverCalendarsAsync(
CalDavConnectionSettings connectionSettings,
CancellationToken cancellationToken = default)
{
ValidateConnectionSettings(connectionSettings);
var principalUri = await DiscoverPrincipalUriAsync(connectionSettings, cancellationToken).ConfigureAwait(false);
var homeSetUri = await DiscoverCalendarHomeSetUriAsync(connectionSettings, principalUri, cancellationToken).ConfigureAwait(false);
var body = """
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<D:prop>
<D:resourcetype />
<D:displayname />
<CS:getctag />
<D:sync-token />
</D:prop>
</D:propfind>
""";
var responseXml = await SendXmlAsync(
connectionSettings,
PropFindMethod,
homeSetUri,
depth: "1",
body,
cancellationToken).ConfigureAwait(false);
var calendars = ParseCalendarCollection(responseXml, homeSetUri)
.GroupBy(c => c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
return calendars;
}
public async Task<IReadOnlyList<CalDavCalendarEvent>> GetCalendarEventsAsync(
CalDavConnectionSettings connectionSettings,
CalDavCalendar calendar,
DateTimeOffset startUtc,
DateTimeOffset endUtc,
CancellationToken cancellationToken = default)
{
ValidateConnectionSettings(connectionSettings);
if (calendar == null || string.IsNullOrWhiteSpace(calendar.RemoteCalendarId))
return [];
var calendarUri = new Uri(calendar.RemoteCalendarId);
var startString = startUtc.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'");
var endString = endUtc.UtcDateTime.ToString("yyyyMMdd'T'HHmmss'Z'");
var body = $"""
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag />
<C:calendar-data />
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="{startString}" end="{endString}" />
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
""";
var responseXml = await SendXmlAsync(
connectionSettings,
ReportMethod,
calendarUri,
depth: "1",
body,
cancellationToken).ConfigureAwait(false);
var eventResponses = ParseEventResponses(responseXml, calendarUri);
var result = new List<CalDavCalendarEvent>();
foreach (var eventResponse in eventResponses)
{
result.AddRange(ParseCalendarData(
eventResponse.CalendarData,
eventResponse.Href,
eventResponse.ETag,
startUtc,
endUtc));
}
// Ensure recurring parents are saved before child occurrences/exceptions.
return result
.OrderByDescending(e => e.IsSeriesMaster)
.ThenBy(e => e.Start)
.ToList();
}
private static void ValidateConnectionSettings(CalDavConnectionSettings connectionSettings)
{
if (connectionSettings?.ServiceUri == null)
throw new ArgumentException("Service URI is required for CalDAV.");
if (string.IsNullOrWhiteSpace(connectionSettings.Username))
throw new ArgumentException("Username is required for CalDAV.");
if (string.IsNullOrWhiteSpace(connectionSettings.Password))
throw new ArgumentException("Password is required for CalDAV.");
}
private async Task<Uri> DiscoverPrincipalUriAsync(CalDavConnectionSettings connectionSettings, CancellationToken cancellationToken)
{
var body = """
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:current-user-principal />
</D:prop>
</D:propfind>
""";
var responseXml = await SendXmlAsync(
connectionSettings,
PropFindMethod,
connectionSettings.ServiceUri,
depth: "0",
body,
cancellationToken).ConfigureAwait(false);
var principalHref = responseXml
.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "current-user-principal")
?.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "href")
?.Value;
return string.IsNullOrWhiteSpace(principalHref)
? connectionSettings.ServiceUri
: CreateAbsoluteUri(connectionSettings.ServiceUri, principalHref);
}
private async Task<Uri> DiscoverCalendarHomeSetUriAsync(
CalDavConnectionSettings connectionSettings,
Uri principalUri,
CancellationToken cancellationToken)
{
var body = """
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-home-set />
</D:prop>
</D:propfind>
""";
var responseXml = await SendXmlAsync(
connectionSettings,
PropFindMethod,
principalUri,
depth: "0",
body,
cancellationToken).ConfigureAwait(false);
var homeSetHref = responseXml
.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "calendar-home-set")
?.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "href")
?.Value;
return string.IsNullOrWhiteSpace(homeSetHref)
? principalUri
: CreateAbsoluteUri(principalUri, homeSetHref);
}
private async Task<XDocument> SendXmlAsync(
CalDavConnectionSettings connectionSettings,
HttpMethod method,
Uri uri,
string depth,
string body,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, uri);
request.Headers.Authorization = new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{connectionSettings.Username}:{connectionSettings.Password}")));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml"));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/xml"));
request.Headers.Add("Depth", depth);
request.Content = new StringContent(body, Encoding.UTF8, "application/xml");
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
throw new UnauthorizedAccessException("CalDAV authorization failed.");
}
if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.MultiStatus)
{
var failureBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new HttpRequestException($"CalDAV request failed ({(int)response.StatusCode}): {failureBody}");
}
var xml = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(xml))
return new XDocument(new XElement("empty"));
return XDocument.Parse(xml);
}
private static List<CalDavCalendar> ParseCalendarCollection(XDocument xml, Uri baseUri)
{
var result = new List<CalDavCalendar>();
foreach (var response in xml.Descendants().Where(e => e.Name.LocalName == "response"))
{
var href = response.Descendants().FirstOrDefault(e => e.Name.LocalName == "href")?.Value;
if (string.IsNullOrWhiteSpace(href))
continue;
foreach (var prop in GetSuccessProps(response))
{
var resourceType = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "resourcetype");
if (resourceType == null)
continue;
var isCalendar = resourceType.Descendants().Any(e => e.Name.LocalName == "calendar");
if (!isCalendar)
continue;
var displayName = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "displayname")?.Value ?? string.Empty;
var ctag = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "getctag")?.Value ?? string.Empty;
var syncToken = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "sync-token")?.Value ?? string.Empty;
var remoteUri = CreateAbsoluteUri(baseUri, href).ToString().TrimEnd('/');
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = WebUtility.UrlDecode(remoteUri.Split('/').LastOrDefault() ?? "Calendar");
}
result.Add(new CalDavCalendar
{
RemoteCalendarId = remoteUri,
Name = displayName,
CTag = ctag,
SyncToken = syncToken
});
}
}
return result;
}
private static IEnumerable<CalDavEventResponse> ParseEventResponses(XDocument xml, Uri baseUri)
{
foreach (var response in xml.Descendants().Where(e => e.Name.LocalName == "response"))
{
var href = response.Descendants().FirstOrDefault(e => e.Name.LocalName == "href")?.Value;
if (string.IsNullOrWhiteSpace(href))
continue;
foreach (var prop in GetSuccessProps(response))
{
var calendarData = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-data")?.Value;
if (string.IsNullOrWhiteSpace(calendarData))
continue;
var eTag = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "getetag")?.Value ?? string.Empty;
yield return new CalDavEventResponse(
CreateAbsoluteUri(baseUri, href).ToString(),
eTag,
calendarData);
}
}
}
private static IEnumerable<XElement> GetSuccessProps(XElement response)
{
foreach (var propstat in response.Elements().Where(e => e.Name.LocalName == "propstat"))
{
var status = propstat.Elements().FirstOrDefault(e => e.Name.LocalName == "status")?.Value ?? string.Empty;
if (!status.Contains(" 200 ", StringComparison.Ordinal))
continue;
var prop = propstat.Elements().FirstOrDefault(e => e.Name.LocalName == "prop");
if (prop != null)
yield return prop;
}
}
private static List<CalDavCalendarEvent> ParseCalendarData(
string icsContent,
string resourceHref,
string eTag,
DateTimeOffset windowStartUtc,
DateTimeOffset windowEndUtc)
{
try
{
var calendar = Calendar.Load(icsContent);
if (calendar?.Events == null || calendar.Events.Count == 0)
return [];
var allEvents = calendar.Events.ToList();
var result = new List<CalDavCalendarEvent>();
var masters = allEvents
.Where(e => e != null && !string.IsNullOrWhiteSpace(e.Uid) && e.RecurrenceId == null)
.GroupBy(e => e.Uid, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
var exceptionMap = allEvents
.Where(e => e != null && !string.IsNullOrWhiteSpace(e.Uid) && e.RecurrenceId != null)
.GroupBy(e => $"{e.Uid}|{GetOccurrenceKey(e.RecurrenceId)}", StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var consumedExceptions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var master in masters)
{
var masterRemoteId = BuildRemoteEventId(master.Uid, null);
var hasRecurrence = HasRecurrence(master);
if (hasRecurrence)
{
result.Add(CreateCalendarEvent(
sourceEvent: master,
start: ToDateTimeOffset(master.Start),
end: ToDateTimeOffset(master.End),
remoteEventId: masterRemoteId,
resourceHref: resourceHref,
eTag: eTag,
icsContent: icsContent,
isSeriesMaster: true,
isRecurringInstance: false,
seriesMasterRemoteEventId: string.Empty,
recurrence: BuildRecurrenceString(master)));
var occurrences = master.GetOccurrences(windowStartUtc.UtcDateTime, windowEndUtc.UtcDateTime);
foreach (var occurrence in occurrences)
{
var key = GetOccurrenceKey(occurrence.Period.StartTime);
var mapKey = $"{master.Uid}|{key}";
var sourceEvent = exceptionMap.TryGetValue(mapKey, out var exceptionEvent)
? exceptionEvent
: master;
if (exceptionEvent != null)
consumedExceptions.Add(mapKey);
var occurrenceStart = ToDateTimeOffset(occurrence.Period.StartTime);
var occurrenceEnd = ToDateTimeOffset(occurrence.Period.EndTime);
result.Add(CreateCalendarEvent(
sourceEvent: sourceEvent,
start: occurrenceStart,
end: occurrenceEnd,
remoteEventId: BuildRemoteEventId(master.Uid, key),
resourceHref: resourceHref,
eTag: eTag,
icsContent: icsContent,
isSeriesMaster: false,
isRecurringInstance: true,
seriesMasterRemoteEventId: masterRemoteId,
recurrence: string.Empty));
}
}
else
{
var start = ToDateTimeOffset(master.Start);
var end = ToDateTimeOffset(master.End);
if (!Overlaps(start, end, windowStartUtc, windowEndUtc))
continue;
result.Add(CreateCalendarEvent(
sourceEvent: master,
start: start,
end: end,
remoteEventId: masterRemoteId,
resourceHref: resourceHref,
eTag: eTag,
icsContent: icsContent,
isSeriesMaster: false,
isRecurringInstance: false,
seriesMasterRemoteEventId: string.Empty,
recurrence: string.Empty));
}
}
foreach (var exceptionEvent in allEvents.Where(e => e != null && e.RecurrenceId != null && !string.IsNullOrWhiteSpace(e.Uid)))
{
var key = $"{exceptionEvent.Uid}|{GetOccurrenceKey(exceptionEvent.RecurrenceId)}";
if (consumedExceptions.Contains(key))
continue;
var start = ToDateTimeOffset(exceptionEvent.Start);
var end = ToDateTimeOffset(exceptionEvent.End);
if (!Overlaps(start, end, windowStartUtc, windowEndUtc))
continue;
var masterRemoteId = BuildRemoteEventId(exceptionEvent.Uid, null);
result.Add(CreateCalendarEvent(
sourceEvent: exceptionEvent,
start: start,
end: end,
remoteEventId: BuildRemoteEventId(exceptionEvent.Uid, GetOccurrenceKey(exceptionEvent.RecurrenceId)),
resourceHref: resourceHref,
eTag: eTag,
icsContent: icsContent,
isSeriesMaster: false,
isRecurringInstance: true,
seriesMasterRemoteEventId: masterRemoteId,
recurrence: string.Empty));
}
return result;
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to parse CalDAV ICS payload.");
return [];
}
}
private static bool HasRecurrence(CalendarEvent calendarEvent)
=> (calendarEvent.RecurrenceRules?.Any() ?? false)
|| (calendarEvent.RecurrenceDates?.Any() ?? false);
private static string BuildRemoteEventId(string uid, string occurrenceKey)
=> string.IsNullOrWhiteSpace(occurrenceKey) ? uid : $"{uid}::{occurrenceKey}";
private static string GetOccurrenceKey(IDateTime dateTime)
=> dateTime.AsUtc.ToString("yyyyMMdd'T'HHmmss'Z'");
private static DateTimeOffset ToDateTimeOffset(IDateTime dateTime)
=> dateTime?.AsDateTimeOffset ?? default;
private static bool Overlaps(DateTimeOffset start, DateTimeOffset end, DateTimeOffset windowStart, DateTimeOffset windowEnd)
{
if (end <= start)
end = start.AddHours(1);
return start < windowEnd && end > windowStart;
}
private static CalDavCalendarEvent CreateCalendarEvent(
CalendarEvent sourceEvent,
DateTimeOffset start,
DateTimeOffset end,
string remoteEventId,
string resourceHref,
string eTag,
string icsContent,
bool isSeriesMaster,
bool isRecurringInstance,
string seriesMasterRemoteEventId,
string recurrence)
{
if (end <= start)
end = start.AddHours(1);
var status = MapStatus(sourceEvent.Status);
var attendees = sourceEvent.Attendees?
.Where(a => a != null && a.Value != null)
.Select(a => new CalDavEventAttendee
{
Name = a.CommonName ?? string.Empty,
Email = NormalizeCalendarEmail(a.Value),
AttendenceStatus = MapAttendeeStatus(a.ParticipationStatus),
IsOrganizer = string.Equals(a.Role, "CHAIR", StringComparison.OrdinalIgnoreCase),
IsOptionalAttendee = string.Equals(a.Role, "OPT-PARTICIPANT", StringComparison.OrdinalIgnoreCase)
})
.Where(a => !string.IsNullOrWhiteSpace(a.Email))
.ToList() ?? [];
var reminders = sourceEvent.Alarms?
.Where(a => a?.Trigger != null && a.Trigger.IsRelative && a.Trigger.Duration.HasValue)
.Select(a => new CalDavEventReminder
{
DurationInSeconds = (int)Math.Abs(a.Trigger.Duration.Value.TotalSeconds),
ReminderType = string.Equals(a.Action, "EMAIL", StringComparison.OrdinalIgnoreCase)
? CalendarItemReminderType.Email
: CalendarItemReminderType.Popup
})
.Where(r => r.DurationInSeconds > 0)
.ToList() ?? [];
return new CalDavCalendarEvent
{
RemoteEventId = remoteEventId,
RemoteResourceHref = resourceHref,
ETag = eTag,
IcsContent = icsContent,
Uid = sourceEvent.Uid ?? string.Empty,
SeriesMasterRemoteEventId = seriesMasterRemoteEventId,
IsSeriesMaster = isSeriesMaster,
IsRecurringInstance = isRecurringInstance,
Title = sourceEvent.Summary ?? string.Empty,
Description = sourceEvent.Description ?? string.Empty,
Location = sourceEvent.Location ?? string.Empty,
Start = start,
End = end,
StartTimeZone = sourceEvent.Start?.TzId ?? string.Empty,
EndTimeZone = sourceEvent.End?.TzId ?? string.Empty,
Recurrence = recurrence,
OrganizerDisplayName = sourceEvent.Organizer?.CommonName ?? string.Empty,
OrganizerEmail = NormalizeCalendarEmail(sourceEvent.Organizer?.Value),
Status = status,
Visibility = MapVisibility(sourceEvent.Class),
ShowAs = MapShowAs(sourceEvent.Transparency),
IsHidden = status == CalendarItemStatus.Cancelled,
Attendees = attendees,
Reminders = reminders
};
}
private static string BuildRecurrenceString(CalendarEvent sourceEvent)
{
var recurrenceLines = new List<string>();
if (sourceEvent.RecurrenceRules != null)
{
recurrenceLines.AddRange(sourceEvent.RecurrenceRules.Select(r => $"RRULE:{r}"));
}
if (sourceEvent.ExceptionDates != null)
{
foreach (var periodList in sourceEvent.ExceptionDates)
{
var dates = periodList
.Where(p => p.StartTime != null)
.Select(p => p.StartTime.AsUtc.ToString("yyyyMMdd'T'HHmmss'Z'"))
.ToList();
if (dates.Count > 0)
{
recurrenceLines.Add($"EXDATE:{string.Join(",", dates)}");
}
}
}
if (sourceEvent.RecurrenceDates != null)
{
foreach (var periodList in sourceEvent.RecurrenceDates)
{
var dates = periodList
.Where(p => p.StartTime != null)
.Select(p => p.StartTime.AsUtc.ToString("yyyyMMdd'T'HHmmss'Z'"))
.ToList();
if (dates.Count > 0)
{
recurrenceLines.Add($"RDATE:{string.Join(",", dates)}");
}
}
}
return recurrenceLines.Count == 0
? string.Empty
: string.Join(Constants.CalendarEventRecurrenceRuleSeperator, recurrenceLines);
}
private static CalendarItemStatus MapStatus(string status)
{
if (string.IsNullOrWhiteSpace(status))
return CalendarItemStatus.Accepted;
if (string.Equals(status, "CANCELLED", StringComparison.OrdinalIgnoreCase))
return CalendarItemStatus.Cancelled;
if (string.Equals(status, "TENTATIVE", StringComparison.OrdinalIgnoreCase))
return CalendarItemStatus.Tentative;
return CalendarItemStatus.Accepted;
}
private static CalendarItemVisibility MapVisibility(string classValue)
{
if (string.IsNullOrWhiteSpace(classValue))
return CalendarItemVisibility.Default;
return classValue.ToUpperInvariant() switch
{
"PUBLIC" => CalendarItemVisibility.Public,
"PRIVATE" => CalendarItemVisibility.Private,
"CONFIDENTIAL" => CalendarItemVisibility.Confidential,
_ => CalendarItemVisibility.Default
};
}
private static CalendarItemShowAs MapShowAs(string transparency)
{
if (string.Equals(transparency, "TRANSPARENT", StringComparison.OrdinalIgnoreCase))
return CalendarItemShowAs.Free;
return CalendarItemShowAs.Busy;
}
private static AttendeeStatus MapAttendeeStatus(string participationStatus)
{
if (string.IsNullOrWhiteSpace(participationStatus))
return AttendeeStatus.NeedsAction;
return participationStatus.ToUpperInvariant() switch
{
"ACCEPTED" => AttendeeStatus.Accepted,
"DECLINED" => AttendeeStatus.Declined,
"TENTATIVE" => AttendeeStatus.Tentative,
_ => AttendeeStatus.NeedsAction
};
}
private static string NormalizeCalendarEmail(Uri emailUri)
{
if (emailUri == null)
return string.Empty;
var value = emailUri.OriginalString;
if (value.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
value = value[7..];
return value;
}
private static Uri CreateAbsoluteUri(Uri baseUri, string href)
{
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
return absolute;
return new Uri(baseUri, href);
}
private sealed record CalDavEventResponse(string Href, string ETag, string CalendarData);
}
+127
View File
@@ -0,0 +1,127 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Interfaces;
namespace Wino.Services;
public class CalendarIcsFileService : ICalendarIcsFileService
{
private readonly INativeAppService _nativeAppService;
private readonly ILogger _logger = Log.ForContext<CalendarIcsFileService>();
public CalendarIcsFileService(INativeAppService nativeAppService)
{
_nativeAppService = nativeAppService;
}
public async Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent)
{
if (accountId == Guid.Empty || calendarId == Guid.Empty || calendarItemId == Guid.Empty || string.IsNullOrWhiteSpace(icsContent))
return;
var folderPath = await GetCalendarItemFolderPathAsync(accountId, calendarId, calendarItemId).ConfigureAwait(false);
var icsPath = Path.Combine(folderPath, "event.ics");
var metaPath = Path.Combine(folderPath, "event.meta.json");
try
{
await File.WriteAllTextAsync(icsPath, icsContent).ConfigureAwait(false);
var metadataContent = string.Join(
Environment.NewLine,
$"CalendarItemId={calendarItemId:N}",
$"RemoteEventId={remoteEventId ?? string.Empty}",
$"RemoteResourceHref={remoteResourceHref ?? string.Empty}",
$"ETag={eTag ?? string.Empty}",
$"UpdatedAtUtc={DateTime.UtcNow:O}");
await File.WriteAllTextAsync(metaPath, metadataContent).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to save ICS file for account {AccountId} and calendar item {CalendarItemId}", accountId, calendarItemId);
}
}
public async Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId)
{
if (accountId == Guid.Empty || calendarItemId == Guid.Empty)
return;
try
{
var accountRootPath = await GetAccountCalendarsRootPathAsync(accountId).ConfigureAwait(false);
if (!Directory.Exists(accountRootPath))
return;
var calendarDirectories = Directory.GetDirectories(accountRootPath);
foreach (var calendarDirectory in calendarDirectories)
{
var itemPath = Path.Combine(calendarDirectory, calendarItemId.ToString("N"));
if (Directory.Exists(itemPath))
{
Directory.Delete(itemPath, recursive: true);
break;
}
}
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to delete ICS folder for account {AccountId} and calendar item {CalendarItemId}", accountId, calendarItemId);
}
}
public async Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId)
{
if (accountId == Guid.Empty || calendarId == Guid.Empty)
return;
try
{
var calendarPath = await GetCalendarFolderPathAsync(accountId, calendarId).ConfigureAwait(false);
if (Directory.Exists(calendarPath))
{
Directory.Delete(calendarPath, recursive: true);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to delete ICS folder for account {AccountId} and calendar {CalendarId}", accountId, calendarId);
}
}
private async Task<string> GetCalendarItemFolderPathAsync(Guid accountId, Guid calendarId, Guid calendarItemId)
{
var calendarPath = await GetCalendarFolderPathAsync(accountId, calendarId).ConfigureAwait(false);
var itemDirectory = Path.Combine(calendarPath, calendarItemId.ToString("N"));
Directory.CreateDirectory(itemDirectory);
return itemDirectory;
}
private async Task<string> GetCalendarFolderPathAsync(Guid accountId, Guid calendarId)
{
var accountRootPath = await GetAccountCalendarsRootPathAsync(accountId).ConfigureAwait(false);
var calendarDirectory = Path.Combine(accountRootPath, calendarId.ToString("N"));
Directory.CreateDirectory(calendarDirectory);
return calendarDirectory;
}
private async Task<string> GetAccountCalendarsRootPathAsync(Guid accountId)
{
var root = await GetIcsRootPathAsync().ConfigureAwait(false);
var accountPath = Path.Combine(root, accountId.ToString("N"), "calendars");
Directory.CreateDirectory(accountPath);
return accountPath;
}
private async Task<string> GetIcsRootPathAsync()
{
var mimeRootPath = await _nativeAppService.GetMimeMessageStoragePath().ConfigureAwait(false);
var icsRootPath = Path.Combine(mimeRootPath, "CalendarIcs");
Directory.CreateDirectory(icsRootPath);
return icsRootPath;
}
}
+2
View File
@@ -89,6 +89,8 @@ public class CalendarService : BaseDatabaseService, ICalendarService
{
await Connection.Table<CalendarItem>().DeleteAsync(x => x.Id == @event.Id).ConfigureAwait(false);
await Connection.Table<CalendarEventAttendee>().DeleteAsync(a => a.CalendarItemId == @event.Id).ConfigureAwait(false);
await Connection.Table<Reminder>().DeleteAsync(r => r.CalendarItemId == @event.Id).ConfigureAwait(false);
await Connection.Table<CalendarAttachment>().DeleteAsync(a => a.CalendarItemId == @event.Id).ConfigureAwait(false);
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(@event));
}
+30
View File
@@ -85,5 +85,35 @@ public class DatabaseService : IDatabaseService
.ExecuteAsync($"ALTER TABLE {nameof(MailItemFolder)} ADD COLUMN {nameof(MailItemFolder.LastUidReconcileUtc)} TEXT NULL")
.ConfigureAwait(false);
}
var customServerColumns = await Connection.GetTableInfoAsync(nameof(CustomServerInformation)).ConfigureAwait(false);
if (!customServerColumns.Any(c => c.Name == nameof(CustomServerInformation.CalDavServiceUrl)))
{
await Connection
.ExecuteAsync($"ALTER TABLE {nameof(CustomServerInformation)} ADD COLUMN {nameof(CustomServerInformation.CalDavServiceUrl)} TEXT NULL")
.ConfigureAwait(false);
}
if (!customServerColumns.Any(c => c.Name == nameof(CustomServerInformation.CalDavUsername)))
{
await Connection
.ExecuteAsync($"ALTER TABLE {nameof(CustomServerInformation)} ADD COLUMN {nameof(CustomServerInformation.CalDavUsername)} TEXT NULL")
.ConfigureAwait(false);
}
if (!customServerColumns.Any(c => c.Name == nameof(CustomServerInformation.CalDavPassword)))
{
await Connection
.ExecuteAsync($"ALTER TABLE {nameof(CustomServerInformation)} ADD COLUMN {nameof(CustomServerInformation.CalDavPassword)} TEXT NULL")
.ConfigureAwait(false);
}
if (!customServerColumns.Any(c => c.Name == nameof(CustomServerInformation.CalendarSupportMode)))
{
await Connection
.ExecuteAsync($"ALTER TABLE {nameof(CustomServerInformation)} ADD COLUMN {nameof(CustomServerInformation.CalendarSupportMode)} INTEGER NOT NULL DEFAULT 0")
.ConfigureAwait(false);
}
}
}
+2
View File
@@ -14,6 +14,7 @@ public static class ServicesContainerSetup
services.AddSingleton<IWinoLogger, WinoLogger>();
services.AddSingleton<ILaunchProtocolService, LaunchProtocolService>();
services.AddSingleton<IMimeFileService, MimeFileService>();
services.AddSingleton<ICalendarIcsFileService, CalendarIcsFileService>();
services.AddTransient<IMimeStorageService, MimeStorageService>();
services.AddTransient<ICalendarService, CalendarService>();
@@ -25,5 +26,6 @@ public static class ServicesContainerSetup
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
services.AddTransient<ICalDavClient, CalDavClient>();
}
}