diff --git a/Wino.Core.Domain/Models/Calendar/CalDavCalendar.cs b/Wino.Core.Domain/Models/Calendar/CalDavCalendar.cs
index edf1890e..dd0dc34f 100644
--- a/Wino.Core.Domain/Models/Calendar/CalDavCalendar.cs
+++ b/Wino.Core.Domain/Models/Calendar/CalDavCalendar.cs
@@ -1,10 +1,19 @@
+using Wino.Core.Domain.Enums;
+
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 Description { get; init; } = string.Empty;
public string CTag { get; init; } = string.Empty;
public string SyncToken { get; init; } = string.Empty;
+ public string TimeZone { get; init; } = string.Empty;
+ public string BackgroundColorHex { get; init; } = string.Empty;
+ public bool IsReadOnly { get; init; }
+ public bool SupportsEvents { get; init; } = true;
+ public CalendarItemShowAs DefaultShowAs { get; init; } = CalendarItemShowAs.Busy;
+ public double? Order { get; init; }
}
diff --git a/Wino.Core.Tests/Services/SpecialImapProviderConfigResolverTests.cs b/Wino.Core.Tests/Services/SpecialImapProviderConfigResolverTests.cs
new file mode 100644
index 00000000..dd1feb2d
--- /dev/null
+++ b/Wino.Core.Tests/Services/SpecialImapProviderConfigResolverTests.cs
@@ -0,0 +1,41 @@
+using FluentAssertions;
+using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Models.Accounts;
+using Wino.Services;
+using Xunit;
+
+namespace Wino.Core.Tests.Services;
+
+public class SpecialImapProviderConfigResolverTests
+{
+ [Fact]
+ public void GetServerInformation_ICloud_UsesMailboxLocalPartForIncomingAndOutgoingUsernames()
+ {
+ var sut = new SpecialImapProviderConfigResolver();
+ var account = new MailAccount
+ {
+ Id = Guid.NewGuid(),
+ Address = "tester@icloud.com"
+ };
+ var dialogResult = new AccountCreationDialogResult(
+ MailProviderType.IMAP4,
+ "iCloud",
+ new SpecialImapProviderDetails(
+ "tester@icloud.com",
+ "app-password",
+ "Tester",
+ SpecialImapProvider.iCloud,
+ ImapCalendarSupportMode.CalDav),
+ "#0078D4",
+ InitialSynchronizationRange.SixMonths,
+ true,
+ true);
+
+ var serverInformation = sut.GetServerInformation(account, dialogResult);
+
+ serverInformation.IncomingServerUsername.Should().Be("tester");
+ serverInformation.OutgoingServerUsername.Should().Be("tester");
+ serverInformation.CalDavUsername.Should().Be("tester@icloud.com");
+ }
+}
diff --git a/Wino.Core.Tests/Synchronizers/CalDavCalendarMetadataTests.cs b/Wino.Core.Tests/Synchronizers/CalDavCalendarMetadataTests.cs
new file mode 100644
index 00000000..4cac123e
--- /dev/null
+++ b/Wino.Core.Tests/Synchronizers/CalDavCalendarMetadataTests.cs
@@ -0,0 +1,262 @@
+using System.Reflection;
+using System.IO;
+using System.Xml.Linq;
+using FluentAssertions;
+using Moq;
+using Wino.Core.Domain.Entities.Calendar;
+using Wino.Core.Domain.Entities.Shared;
+using Wino.Core.Domain.Enums;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Domain.Models.Calendar;
+using Wino.Core.Integration.Processors;
+using Wino.Core.Misc;
+using Wino.Core.Synchronizers.ImapSync;
+using Wino.Core.Synchronizers.Mail;
+using Wino.Services;
+using Xunit;
+
+namespace Wino.Core.Tests.Synchronizers;
+
+public class CalDavCalendarMetadataTests
+{
+ [Fact]
+ public void ParseCalendarCollection_MapsCollectionMetadataAndSkipsNonEventCalendars()
+ {
+ var xml = XDocument.Parse(
+ """
+
+
+ /calendars/work/
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+ Work
+ Team calendar
+ "ctag-1"
+ sync-1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #5b617aff
+ 2
+
+
+
+
+ /calendars/tasks/
+
+ HTTP/1.1 200 OK
+
+
+
+
+
+ Tasks
+
+
+
+
+
+
+
+ """);
+
+ var calendars = ParseCalendars(xml, new Uri("https://calendar.example.com/"));
+
+ calendars.Should().ContainSingle();
+
+ var calendar = calendars[0];
+ calendar.RemoteCalendarId.Should().Be("https://calendar.example.com/calendars/work");
+ calendar.Name.Should().Be("Work");
+ calendar.Description.Should().Be("Team calendar");
+ calendar.CTag.Should().Be("\"ctag-1\"");
+ calendar.SyncToken.Should().Be("sync-1");
+ calendar.TimeZone.Should().Be("Europe/Warsaw");
+ calendar.BackgroundColorHex.Should().Be("#5B617A");
+ calendar.IsReadOnly.Should().BeTrue();
+ calendar.SupportsEvents.Should().BeTrue();
+ calendar.DefaultShowAs.Should().Be(CalendarItemShowAs.Free);
+ calendar.Order.Should().Be(2d);
+ }
+
+ [Fact]
+ public async Task SynchronizeCalendarMetadataAsync_UpdatesServerBackedSettingsAndPreservesUserColorOverride()
+ {
+ var tempDirectory = CreateTempDirectory();
+
+ var serverInformation = new CustomServerInformation
+ {
+ Id = Guid.NewGuid(),
+ IncomingServer = "imap.example.com",
+ IncomingServerPort = "993",
+ IncomingServerUsername = "user@example.com",
+ IncomingServerPassword = "password",
+ OutgoingServer = "smtp.example.com",
+ OutgoingServerPort = "587",
+ OutgoingServerUsername = "user@example.com",
+ OutgoingServerPassword = "password",
+ MaxConcurrentClients = 5,
+ CalendarSupportMode = ImapCalendarSupportMode.CalDav
+ };
+
+ var account = new MailAccount
+ {
+ Id = Guid.NewGuid(),
+ Name = "IMAP Test",
+ Address = "test@example.com",
+ ProviderType = MailProviderType.IMAP4,
+ IsCalendarAccessGranted = true,
+ ServerInformation = serverInformation
+ };
+
+ var localCalendar = new AccountCalendar
+ {
+ Id = Guid.NewGuid(),
+ AccountId = account.Id,
+ RemoteCalendarId = "https://calendar.example.com/calendars/work",
+ Name = "Local",
+ BackgroundColorHex = "#123456",
+ TextColorHex = "#FFFFFF",
+ IsBackgroundColorUserOverridden = true,
+ TimeZone = "UTC",
+ IsReadOnly = false,
+ DefaultShowAs = CalendarItemShowAs.Busy
+ };
+
+ var changeProcessor = new Mock();
+ changeProcessor
+ .Setup(x => x.GetAccountCalendarsAsync(account.Id))
+ .ReturnsAsync(new List { localCalendar });
+ changeProcessor
+ .Setup(x => x.UpdateAccountCalendarAsync(It.IsAny()))
+ .Returns(Task.CompletedTask);
+ changeProcessor
+ .Setup(x => x.DeleteCalendarIcsForCalendarAsync(It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+ changeProcessor
+ .Setup(x => x.DeleteAccountCalendarAsync(It.IsAny()))
+ .Returns(Task.CompletedTask);
+ changeProcessor
+ .Setup(x => x.InsertAccountCalendarAsync(It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var synchronizer = CreateSynchronizer(tempDirectory, account, changeProcessor.Object);
+
+ try
+ {
+ await InvokePrivateAsync(
+ synchronizer,
+ "SynchronizeCalendarMetadataAsync",
+ new List
+ {
+ new()
+ {
+ RemoteCalendarId = localCalendar.RemoteCalendarId,
+ Name = "Remote",
+ TimeZone = "Europe/Warsaw",
+ BackgroundColorHex = "#ABCDEF",
+ IsReadOnly = true,
+ DefaultShowAs = CalendarItemShowAs.Free,
+ Order = 0
+ }
+ });
+
+ localCalendar.Name.Should().Be("Remote");
+ localCalendar.TimeZone.Should().Be("Europe/Warsaw");
+ localCalendar.IsReadOnly.Should().BeTrue();
+ localCalendar.DefaultShowAs.Should().Be(CalendarItemShowAs.Free);
+ localCalendar.IsPrimary.Should().BeTrue();
+ localCalendar.BackgroundColorHex.Should().Be("#123456");
+ localCalendar.TextColorHex.Should().Be(ColorHelpers.GetReadableTextColorHex("#123456"));
+
+ changeProcessor.Verify(x => x.UpdateAccountCalendarAsync(localCalendar), Times.Once);
+ changeProcessor.Verify(x => x.InsertAccountCalendarAsync(It.IsAny()), Times.Never);
+ changeProcessor.Verify(x => x.DeleteAccountCalendarAsync(It.IsAny()), Times.Never);
+ }
+ finally
+ {
+ await synchronizer.KillSynchronizerAsync();
+ DeleteDirectory(tempDirectory);
+ }
+ }
+
+ private static List ParseCalendars(XDocument xml, Uri baseUri)
+ {
+ var parseMethod = typeof(CalDavClient).GetMethod(
+ "ParseCalendarCollection",
+ BindingFlags.NonPublic | BindingFlags.Static);
+
+ parseMethod.Should().NotBeNull();
+
+ var result = parseMethod!.Invoke(null, [xml, baseUri]);
+ return result.Should().BeOfType>().Subject;
+ }
+
+ private static ImapSynchronizer CreateSynchronizer(string appDataFolder, MailAccount account, IImapChangeProcessor changeProcessor)
+ {
+ var applicationConfiguration = new Mock();
+ applicationConfiguration.SetupProperty(x => x.ApplicationDataFolderPath, appDataFolder);
+ applicationConfiguration.SetupProperty(x => x.PublisherSharedFolderPath, appDataFolder);
+ applicationConfiguration.SetupProperty(x => x.ApplicationTempFolderPath, appDataFolder);
+ applicationConfiguration.SetupGet(x => x.SentryDNS).Returns(string.Empty);
+
+ var unifiedSynchronizer = new UnifiedImapSynchronizer(
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of());
+
+ return new ImapSynchronizer(
+ account,
+ changeProcessor,
+ applicationConfiguration.Object,
+ unifiedSynchronizer,
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of(),
+ Mock.Of());
+ }
+
+ private static string CreateTempDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "wino-caldav-calendar-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private static void DeleteDirectory(string path)
+ {
+ if (Directory.Exists(path))
+ {
+ Directory.Delete(path, recursive: true);
+ }
+ }
+
+ private static async Task InvokePrivateAsync(object instance, string methodName, params object[] parameters)
+ {
+ var method = instance.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)
+ ?? throw new InvalidOperationException($"Method '{methodName}' not found.");
+
+ var task = (Task)method.Invoke(instance, parameters)!;
+ await task.ConfigureAwait(false);
+ }
+}
diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs
index 94c6ad9a..df153079 100644
--- a/Wino.Core/Synchronizers/ImapSynchronizer.cs
+++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs
@@ -1522,7 +1522,7 @@ public class ImapSynchronizer : WinoSynchronizer g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var usedCalendarColors = new HashSet(StringComparer.OrdinalIgnoreCase);
- var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId;
+ var remotePrimaryCalendarId = GetPrimaryCalDavCalendarId(remoteCalendars);
foreach (var localCalendar in localCalendars.ToList())
{
@@ -1545,6 +1545,7 @@ public class ImapSynchronizer : WinoSynchronizer remoteCalendars)
+ {
+ if (remoteCalendars == null || remoteCalendars.Count == 0)
+ return string.Empty;
+
+ if (remoteCalendars.Any(calendar => calendar.Order.HasValue))
+ {
+ return remoteCalendars
+ .OrderBy(calendar => calendar.Order ?? double.MaxValue)
+ .ThenBy(calendar => calendar.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(calendar => calendar.RemoteCalendarId)
+ .FirstOrDefault() ?? string.Empty;
+ }
+
+ return remoteCalendars.First().RemoteCalendarId;
+ }
+
+ private static string ResolveSynchronizedCalendarBackgroundColor(
+ string remoteBackgroundColor,
+ AccountCalendar accountCalendar,
+ ISet usedCalendarColors = null)
+ {
+ if (accountCalendar?.IsBackgroundColorUserOverridden == true)
+ return accountCalendar.BackgroundColorHex;
+
+ var preferredColor = string.IsNullOrWhiteSpace(remoteBackgroundColor)
+ ? accountCalendar?.BackgroundColorHex
+ : remoteBackgroundColor;
+
+ if (string.IsNullOrWhiteSpace(remoteBackgroundColor) && usedCalendarColors != null)
+ return ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, preferredColor);
+
+ return string.IsNullOrWhiteSpace(preferredColor)
+ ? ColorHelpers.GenerateFlatColorHex()
+ : preferredColor;
+ }
+
private interface IImapCalendarOperationHandler
{
bool RequiresConnectedClient { get; }
diff --git a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs
index 30cb94fb..0c77fb77 100644
--- a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs
+++ b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs
@@ -613,6 +613,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
var normalizedEmail = !string.IsNullOrWhiteSpace(EmailAddress) && !EmailAddress.Contains('@')
? $"{EmailAddress}@icloud.com"
: EmailAddress;
+ var iCloudMailboxUsername = GetICloudMailboxUsername(normalizedEmail);
if (!string.IsNullOrWhiteSpace(accountCreationDialogResult?.SpecialImapProviderDetails?.SenderName))
DisplayName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName;
@@ -632,10 +633,10 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
ApplySpecialProviderDefaults(
"imap.mail.me.com",
"993",
- normalizedEmail,
+ iCloudMailboxUsername,
"smtp.mail.me.com",
"587",
- normalizedEmail,
+ iCloudMailboxUsername,
Password,
"https://caldav.icloud.com/",
normalizedEmail,
@@ -714,6 +715,19 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
OutgoingServerPort = "587";
}
+ private static string GetICloudMailboxUsername(string emailAddress)
+ {
+ if (string.IsNullOrWhiteSpace(emailAddress))
+ return string.Empty;
+
+ var normalizedAddress = emailAddress.Trim();
+ var atIndex = normalizedAddress.IndexOf('@');
+
+ return atIndex > 0
+ ? normalizedAddress[..atIndex]
+ : normalizedAddress;
+ }
+
private static string ReplaceIfEmptyOrMatchingPrevious(string currentValue, string previousValue, string replacementValue)
{
var normalizedCurrentValue = currentValue?.Trim() ?? string.Empty;
diff --git a/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs b/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs
index 76f1c641..eff3a1d0 100644
--- a/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs
+++ b/Wino.Mail.ViewModels/SpecialImapCredentialsPageViewModel.cs
@@ -125,7 +125,7 @@ public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel
{
if (!CanProceed) return;
- if (await _accountService.AccountAddressExistsAsync(EmailAddress).ConfigureAwait(false))
+ if (await _accountService.AccountAddressExistsAsync(EmailAddress))
{
await _dialogService.ShowMessageAsync(
Translator.DialogMessage_AccountAddressExistsMessage,
diff --git a/Wino.Services/CalDavClient.cs b/Wino.Services/CalDavClient.cs
index 285a7145..1cf96f93 100644
--- a/Wino.Services/CalDavClient.cs
+++ b/Wino.Services/CalDavClient.cs
@@ -44,12 +44,19 @@ public sealed class CalDavClient : ICalDavClient
var homeSetUri = await DiscoverCalendarHomeSetUriAsync(connectionSettings, principalUri, cancellationToken).ConfigureAwait(false);
var body = """
-
+
+
+
+
+
+
+
+
""";
@@ -344,10 +351,32 @@ public sealed class CalDavClient : ICalDavClient
continue;
var displayName = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "displayname")?.Value ?? string.Empty;
+ var description = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-description")?.Value ?? string.Empty;
var ctag = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "getctag")?.Value ?? string.Empty;
var syncToken = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "sync-token")?.Value ?? string.Empty;
+ var timeZone = ExtractCalendarTimeZoneId(
+ prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-timezone")?.Value);
+ var backgroundColor = NormalizeCalendarColor(
+ prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-color")?.Value);
+ var supportedComponents = prop
+ .Descendants()
+ .Where(e => e.Name.LocalName == "supported-calendar-component-set")
+ .Descendants()
+ .Where(e => e.Name.LocalName == "comp")
+ .Select(e => e.Attribute("name")?.Value?.Trim())
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .ToList();
+ var supportsEvents = supportedComponents.Count == 0 ||
+ supportedComponents.Contains("VEVENT", StringComparer.OrdinalIgnoreCase);
+ var isReadOnly = IsCalendarReadOnly(prop);
+ var defaultShowAs = GetDefaultShowAs(prop);
+ var calendarOrder = ParseCalendarOrder(
+ prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-order")?.Value);
var remoteUri = CreateAbsoluteUri(baseUri, href).ToString().TrimEnd('/');
+ if (!supportsEvents)
+ continue;
+
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = WebUtility.UrlDecode(remoteUri.Split('/').LastOrDefault() ?? "Calendar");
@@ -357,8 +386,15 @@ public sealed class CalDavClient : ICalDavClient
{
RemoteCalendarId = remoteUri,
Name = displayName,
+ Description = description,
CTag = ctag,
- SyncToken = syncToken
+ SyncToken = syncToken,
+ TimeZone = timeZone,
+ BackgroundColorHex = backgroundColor,
+ IsReadOnly = isReadOnly,
+ SupportsEvents = supportsEvents,
+ DefaultShowAs = defaultShowAs,
+ Order = calendarOrder
});
}
}
@@ -820,6 +856,152 @@ public sealed class CalDavClient : ICalDavClient
return false;
}
+ private static bool IsCalendarReadOnly(XElement prop)
+ {
+ var privilegeSet = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "current-user-privilege-set");
+ if (privilegeSet == null)
+ return false;
+
+ var privilegeNames = privilegeSet
+ .Descendants()
+ .Where(e => e.Name.LocalName == "privilege")
+ .Descendants()
+ .Select(e => e.Name.LocalName)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ return !privilegeNames.Contains("all")
+ && !privilegeNames.Contains("write")
+ && !privilegeNames.Contains("write-content");
+ }
+
+ private static CalendarItemShowAs GetDefaultShowAs(XElement prop)
+ {
+ var transparency = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "schedule-calendar-transp");
+ if (transparency?.Descendants().Any(e => e.Name.LocalName == "transparent") == true)
+ return CalendarItemShowAs.Free;
+
+ return CalendarItemShowAs.Busy;
+ }
+
+ private static double? ParseCalendarOrder(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return null;
+
+ return double.TryParse(value.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var result)
+ ? result
+ : null;
+ }
+
+ private static string ExtractCalendarTimeZoneId(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return string.Empty;
+
+ var unfoldedValue = UnfoldIcsText(TrimCommonIndentation(value));
+ foreach (var rawLine in unfoldedValue.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var line = rawLine.Trim();
+ if (string.IsNullOrWhiteSpace(line))
+ continue;
+
+ var separatorIndex = line.IndexOf(':');
+ if (separatorIndex <= 0)
+ continue;
+
+ var propertyName = line[..separatorIndex];
+ var propertyValue = line[(separatorIndex + 1)..].Trim();
+
+ if (propertyName.StartsWith("TZID", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(propertyValue))
+ return propertyValue;
+
+ if (propertyName.StartsWith("X-WR-TIMEZONE", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(propertyValue))
+ return propertyValue;
+ }
+
+ return value.Trim();
+ }
+
+ private static string UnfoldIcsText(string value)
+ {
+ var normalizedValue = value
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace('\r', '\n');
+ var unfoldedLines = new List();
+
+ foreach (var rawLine in normalizedValue.Split('\n'))
+ {
+ if ((rawLine.StartsWith(' ') || rawLine.StartsWith('\t')) && unfoldedLines.Count > 0)
+ {
+ unfoldedLines[^1] += rawLine.TrimStart(' ', '\t');
+ continue;
+ }
+
+ unfoldedLines.Add(rawLine);
+ }
+
+ return string.Join("\n", unfoldedLines);
+ }
+
+ private static string TrimCommonIndentation(string value)
+ {
+ var normalizedValue = value
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace('\r', '\n');
+ var lines = normalizedValue.Split('\n');
+ var nonEmptyLines = lines
+ .Where(line => !string.IsNullOrWhiteSpace(line))
+ .ToList();
+
+ if (nonEmptyLines.Count == 0)
+ return normalizedValue;
+
+ var commonIndentation = nonEmptyLines
+ .Select(line => line.TakeWhile(ch => ch is ' ' or '\t').Count())
+ .Min();
+
+ if (commonIndentation <= 0)
+ return normalizedValue;
+
+ return string.Join(
+ "\n",
+ lines.Select(line =>
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ return string.Empty;
+
+ return line.Length >= commonIndentation
+ ? line[commonIndentation..]
+ : line.TrimStart(' ', '\t');
+ }));
+ }
+
+ private static string NormalizeCalendarColor(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return string.Empty;
+
+ var color = value.Trim();
+ if (color.StartsWith('#'))
+ {
+ color = color[1..];
+ }
+
+ if (color.Length == 8)
+ {
+ color = color[..6];
+ }
+ else if (color.Length == 3)
+ {
+ color = string.Concat(color.Select(c => $"{c}{c}"));
+ }
+
+ if (color.Length != 6 || !int.TryParse(color, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out _))
+ return string.Empty;
+
+ return $"#{color.ToUpperInvariant()}";
+ }
+
private static Uri CreateAbsoluteUri(Uri baseUri, string href)
{
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
diff --git a/Wino.Services/SpecialImapProviderConfigResolver.cs b/Wino.Services/SpecialImapProviderConfigResolver.cs
index a76457e5..439b9bfa 100644
--- a/Wino.Services/SpecialImapProviderConfigResolver.cs
+++ b/Wino.Services/SpecialImapProviderConfigResolver.cs
@@ -14,6 +14,8 @@ public class SpecialImapProviderConfigResolver : ISpecialImapProviderConfigResol
if (details.SpecialImapProvider == SpecialImapProvider.iCloud)
{
+ var iCloudMailboxUsername = GetICloudMailboxUsername(details.Address);
+
resolvedConfig = new CustomServerInformation()
{
IncomingServer = "imap.mail.me.com",
@@ -29,9 +31,9 @@ public class SpecialImapProviderConfigResolver : ISpecialImapProviderConfigResol
CalDavServiceUrl = "https://caldav.icloud.com/"
};
- // iCloud takes username before the @icloud part for incoming, but full address as outgoing.
- resolvedConfig.IncomingServerUsername = details.Address.Split('@')[0];
- resolvedConfig.OutgoingServerUsername = details.Address;
+ // iCloud IMAP/SMTP authentication uses only the local-part mailbox username.
+ resolvedConfig.IncomingServerUsername = iCloudMailboxUsername;
+ resolvedConfig.OutgoingServerUsername = iCloudMailboxUsername;
}
else if (details.SpecialImapProvider == SpecialImapProvider.Yahoo)
{
@@ -73,4 +75,17 @@ public class SpecialImapProviderConfigResolver : ISpecialImapProviderConfigResol
return resolvedConfig;
}
+
+ private static string GetICloudMailboxUsername(string address)
+ {
+ if (string.IsNullOrWhiteSpace(address))
+ return string.Empty;
+
+ var normalizedAddress = address.Trim();
+ var atIndex = normalizedAddress.IndexOf('@');
+
+ return atIndex > 0
+ ? normalizedAddress[..atIndex]
+ : normalizedAddress;
+ }
}