Fixed iCloud/Yahoo special providers and implemented more calendar metadata to support calendar colors in CalDav synchronizer.

This commit is contained in:
Burak Kaan Köse
2026-04-22 09:55:13 +02:00
parent 890bfc84f1
commit d66015bebd
8 changed files with 585 additions and 14 deletions
@@ -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; }
}
@@ -0,0 +1,41 @@
using FluentAssertions;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts;
using Wino.Services;
using Xunit;
namespace Wino.Core.Tests.Services;
public class SpecialImapProviderConfigResolverTests
{
[Fact]
public void GetServerInformation_ICloud_UsesMailboxLocalPartForIncomingAndOutgoingUsernames()
{
var sut = new SpecialImapProviderConfigResolver();
var account = new MailAccount
{
Id = Guid.NewGuid(),
Address = "tester@icloud.com"
};
var dialogResult = new AccountCreationDialogResult(
MailProviderType.IMAP4,
"iCloud",
new SpecialImapProviderDetails(
"tester@icloud.com",
"app-password",
"Tester",
SpecialImapProvider.iCloud,
ImapCalendarSupportMode.CalDav),
"#0078D4",
InitialSynchronizationRange.SixMonths,
true,
true);
var serverInformation = sut.GetServerInformation(account, dialogResult);
serverInformation.IncomingServerUsername.Should().Be("tester");
serverInformation.OutgoingServerUsername.Should().Be("tester");
serverInformation.CalDavUsername.Should().Be("tester@icloud.com");
}
}
@@ -0,0 +1,262 @@
using System.Reflection;
using System.IO;
using System.Xml.Linq;
using FluentAssertions;
using Moq;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Integration.Processors;
using Wino.Core.Misc;
using Wino.Core.Synchronizers.ImapSync;
using Wino.Core.Synchronizers.Mail;
using Wino.Services;
using Xunit;
namespace Wino.Core.Tests.Synchronizers;
public class CalDavCalendarMetadataTests
{
[Fact]
public void ParseCalendarCollection_MapsCollectionMetadataAndSkipsNonEventCalendars()
{
var xml = XDocument.Parse(
"""
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<D:response>
<D:href>/calendars/work/</D:href>
<D:propstat>
<D:status>HTTP/1.1 200 OK</D:status>
<D:prop>
<D:resourcetype>
<D:collection />
<C:calendar />
</D:resourcetype>
<D:displayname>Work</D:displayname>
<C:calendar-description>Team calendar</C:calendar-description>
<CS:getctag>"ctag-1"</CS:getctag>
<D:sync-token>sync-1</D:sync-token>
<D:current-user-privilege-set>
<D:privilege>
<D:read />
</D:privilege>
</D:current-user-privilege-set>
<C:calendar-timezone><![CDATA[
BEGIN:VCALENDAR
BEGIN:VTIMEZONE
TZID:Europe/Warsaw
END:VTIMEZONE
END:VCALENDAR
]]></C:calendar-timezone>
<C:supported-calendar-component-set>
<C:comp name="VEVENT" />
<C:comp name="VTODO" />
</C:supported-calendar-component-set>
<C:schedule-calendar-transp>
<C:transparent />
</C:schedule-calendar-transp>
<ICAL:calendar-color>#5b617aff</ICAL:calendar-color>
<ICAL:calendar-order>2</ICAL:calendar-order>
</D:prop>
</D:propstat>
</D:response>
<D:response>
<D:href>/calendars/tasks/</D:href>
<D:propstat>
<D:status>HTTP/1.1 200 OK</D:status>
<D:prop>
<D:resourcetype>
<D:collection />
<C:calendar />
</D:resourcetype>
<D:displayname>Tasks</D:displayname>
<C:supported-calendar-component-set>
<C:comp name="VTODO" />
</C:supported-calendar-component-set>
</D:prop>
</D:propstat>
</D:response>
</D:multistatus>
""");
var calendars = ParseCalendars(xml, new Uri("https://calendar.example.com/"));
calendars.Should().ContainSingle();
var calendar = calendars[0];
calendar.RemoteCalendarId.Should().Be("https://calendar.example.com/calendars/work");
calendar.Name.Should().Be("Work");
calendar.Description.Should().Be("Team calendar");
calendar.CTag.Should().Be("\"ctag-1\"");
calendar.SyncToken.Should().Be("sync-1");
calendar.TimeZone.Should().Be("Europe/Warsaw");
calendar.BackgroundColorHex.Should().Be("#5B617A");
calendar.IsReadOnly.Should().BeTrue();
calendar.SupportsEvents.Should().BeTrue();
calendar.DefaultShowAs.Should().Be(CalendarItemShowAs.Free);
calendar.Order.Should().Be(2d);
}
[Fact]
public async Task SynchronizeCalendarMetadataAsync_UpdatesServerBackedSettingsAndPreservesUserColorOverride()
{
var tempDirectory = CreateTempDirectory();
var serverInformation = new CustomServerInformation
{
Id = Guid.NewGuid(),
IncomingServer = "imap.example.com",
IncomingServerPort = "993",
IncomingServerUsername = "user@example.com",
IncomingServerPassword = "password",
OutgoingServer = "smtp.example.com",
OutgoingServerPort = "587",
OutgoingServerUsername = "user@example.com",
OutgoingServerPassword = "password",
MaxConcurrentClients = 5,
CalendarSupportMode = ImapCalendarSupportMode.CalDav
};
var account = new MailAccount
{
Id = Guid.NewGuid(),
Name = "IMAP Test",
Address = "test@example.com",
ProviderType = MailProviderType.IMAP4,
IsCalendarAccessGranted = true,
ServerInformation = serverInformation
};
var localCalendar = new AccountCalendar
{
Id = Guid.NewGuid(),
AccountId = account.Id,
RemoteCalendarId = "https://calendar.example.com/calendars/work",
Name = "Local",
BackgroundColorHex = "#123456",
TextColorHex = "#FFFFFF",
IsBackgroundColorUserOverridden = true,
TimeZone = "UTC",
IsReadOnly = false,
DefaultShowAs = CalendarItemShowAs.Busy
};
var changeProcessor = new Mock<IImapChangeProcessor>();
changeProcessor
.Setup(x => x.GetAccountCalendarsAsync(account.Id))
.ReturnsAsync(new List<AccountCalendar> { localCalendar });
changeProcessor
.Setup(x => x.UpdateAccountCalendarAsync(It.IsAny<AccountCalendar>()))
.Returns(Task.CompletedTask);
changeProcessor
.Setup(x => x.DeleteCalendarIcsForCalendarAsync(It.IsAny<Guid>(), It.IsAny<Guid>()))
.Returns(Task.CompletedTask);
changeProcessor
.Setup(x => x.DeleteAccountCalendarAsync(It.IsAny<AccountCalendar>()))
.Returns(Task.CompletedTask);
changeProcessor
.Setup(x => x.InsertAccountCalendarAsync(It.IsAny<AccountCalendar>()))
.Returns(Task.CompletedTask);
var synchronizer = CreateSynchronizer(tempDirectory, account, changeProcessor.Object);
try
{
await InvokePrivateAsync(
synchronizer,
"SynchronizeCalendarMetadataAsync",
new List<CalDavCalendar>
{
new()
{
RemoteCalendarId = localCalendar.RemoteCalendarId,
Name = "Remote",
TimeZone = "Europe/Warsaw",
BackgroundColorHex = "#ABCDEF",
IsReadOnly = true,
DefaultShowAs = CalendarItemShowAs.Free,
Order = 0
}
});
localCalendar.Name.Should().Be("Remote");
localCalendar.TimeZone.Should().Be("Europe/Warsaw");
localCalendar.IsReadOnly.Should().BeTrue();
localCalendar.DefaultShowAs.Should().Be(CalendarItemShowAs.Free);
localCalendar.IsPrimary.Should().BeTrue();
localCalendar.BackgroundColorHex.Should().Be("#123456");
localCalendar.TextColorHex.Should().Be(ColorHelpers.GetReadableTextColorHex("#123456"));
changeProcessor.Verify(x => x.UpdateAccountCalendarAsync(localCalendar), Times.Once);
changeProcessor.Verify(x => x.InsertAccountCalendarAsync(It.IsAny<AccountCalendar>()), Times.Never);
changeProcessor.Verify(x => x.DeleteAccountCalendarAsync(It.IsAny<AccountCalendar>()), Times.Never);
}
finally
{
await synchronizer.KillSynchronizerAsync();
DeleteDirectory(tempDirectory);
}
}
private static List<CalDavCalendar> ParseCalendars(XDocument xml, Uri baseUri)
{
var parseMethod = typeof(CalDavClient).GetMethod(
"ParseCalendarCollection",
BindingFlags.NonPublic | BindingFlags.Static);
parseMethod.Should().NotBeNull();
var result = parseMethod!.Invoke(null, [xml, baseUri]);
return result.Should().BeOfType<List<CalDavCalendar>>().Subject;
}
private static ImapSynchronizer CreateSynchronizer(string appDataFolder, MailAccount account, IImapChangeProcessor changeProcessor)
{
var applicationConfiguration = new Mock<IApplicationConfiguration>();
applicationConfiguration.SetupProperty(x => x.ApplicationDataFolderPath, appDataFolder);
applicationConfiguration.SetupProperty(x => x.PublisherSharedFolderPath, appDataFolder);
applicationConfiguration.SetupProperty(x => x.ApplicationTempFolderPath, appDataFolder);
applicationConfiguration.SetupGet(x => x.SentryDNS).Returns(string.Empty);
var unifiedSynchronizer = new UnifiedImapSynchronizer(
Mock.Of<IFolderService>(),
Mock.Of<IMailService>(),
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
return new ImapSynchronizer(
account,
changeProcessor,
applicationConfiguration.Object,
unifiedSynchronizer,
Mock.Of<IImapSynchronizerErrorHandlerFactory>(),
Mock.Of<ICalDavClient>(),
Mock.Of<IAutoDiscoveryService>(),
Mock.Of<ICalendarService>());
}
private static string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "wino-caldav-calendar-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static void DeleteDirectory(string path)
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
private static async Task InvokePrivateAsync(object instance, string methodName, params object[] parameters)
{
var method = instance.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException($"Method '{methodName}' not found.");
var task = (Task)method.Invoke(instance, parameters)!;
await task.ConfigureAwait(false);
}
}
+54 -6
View File
@@ -1522,7 +1522,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var usedCalendarColors = new HashSet<string>(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<ImapRequest, ImapMessageCreatio
if (existingLocal == null)
{
var insertedCalendarColor = ResolveSynchronizedCalendarBackgroundColor(remoteCalendar.BackgroundColorHex, null, usedCalendarColors);
var newCalendar = new AccountCalendar
{
Id = Guid.NewGuid(),
@@ -1552,10 +1553,12 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
RemoteCalendarId = remoteCalendar.RemoteCalendarId,
Name = remoteCalendar.Name,
IsPrimary = isPrimary,
IsReadOnly = remoteCalendar.IsReadOnly,
IsSynchronizationEnabled = true,
IsExtended = true,
BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors),
TimeZone = "UTC",
DefaultShowAs = remoteCalendar.DefaultShowAs,
BackgroundColorHex = insertedCalendarColor,
TimeZone = remoteCalendar.TimeZone,
SynchronizationDeltaToken = string.Empty
};
@@ -1565,10 +1568,15 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
continue;
}
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocal.BackgroundColorHex);
var resolvedColor = ResolveSynchronizedCalendarBackgroundColor(remoteCalendar.BackgroundColorHex, existingLocal, usedCalendarColors);
var resolvedTextColor = ColorHelpers.GetReadableTextColorHex(resolvedColor);
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|| !string.Equals(existingLocal.TimeZone, remoteCalendar.TimeZone, StringComparison.OrdinalIgnoreCase)
|| existingLocal.IsReadOnly != remoteCalendar.IsReadOnly
|| existingLocal.DefaultShowAs != remoteCalendar.DefaultShowAs
|| existingLocal.IsPrimary != isPrimary
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase);
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(existingLocal.TextColorHex, resolvedTextColor, StringComparison.OrdinalIgnoreCase);
if (!shouldUpdate)
{
@@ -1577,14 +1585,54 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
}
existingLocal.Name = remoteCalendar.Name;
existingLocal.TimeZone = remoteCalendar.TimeZone;
existingLocal.IsReadOnly = remoteCalendar.IsReadOnly;
existingLocal.DefaultShowAs = remoteCalendar.DefaultShowAs;
existingLocal.IsPrimary = isPrimary;
existingLocal.BackgroundColorHex = resolvedColor;
existingLocal.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocal.BackgroundColorHex);
existingLocal.TextColorHex = resolvedTextColor;
usedCalendarColors.Add(existingLocal.BackgroundColorHex);
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
}
}
private static string GetPrimaryCalDavCalendarId(IReadOnlyList<CalDavCalendar> remoteCalendars)
{
if (remoteCalendars == null || remoteCalendars.Count == 0)
return string.Empty;
if (remoteCalendars.Any(calendar => calendar.Order.HasValue))
{
return remoteCalendars
.OrderBy(calendar => calendar.Order ?? double.MaxValue)
.ThenBy(calendar => calendar.Name, StringComparer.OrdinalIgnoreCase)
.Select(calendar => calendar.RemoteCalendarId)
.FirstOrDefault() ?? string.Empty;
}
return remoteCalendars.First().RemoteCalendarId;
}
private static string ResolveSynchronizedCalendarBackgroundColor(
string remoteBackgroundColor,
AccountCalendar accountCalendar,
ISet<string> usedCalendarColors = null)
{
if (accountCalendar?.IsBackgroundColorUserOverridden == true)
return accountCalendar.BackgroundColorHex;
var preferredColor = string.IsNullOrWhiteSpace(remoteBackgroundColor)
? accountCalendar?.BackgroundColorHex
: remoteBackgroundColor;
if (string.IsNullOrWhiteSpace(remoteBackgroundColor) && usedCalendarColors != null)
return ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, preferredColor);
return string.IsNullOrWhiteSpace(preferredColor)
? ColorHelpers.GenerateFlatColorHex()
: preferredColor;
}
private interface IImapCalendarOperationHandler
{
bool RequiresConnectedClient { get; }
@@ -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;
@@ -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,
+184 -2
View File
@@ -44,12 +44,19 @@ public sealed class CalDavClient : ICalDavClient
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:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<D:prop>
<D:resourcetype />
<D:displayname />
<D:current-user-privilege-set />
<CS:getctag />
<D:sync-token />
<C:calendar-description />
<C:calendar-timezone />
<C:supported-calendar-component-set />
<C:schedule-calendar-transp />
<ICAL:calendar-color />
<ICAL:calendar-order />
</D:prop>
</D:propfind>
""";
@@ -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<string>();
foreach (var rawLine in normalizedValue.Split('\n'))
{
if ((rawLine.StartsWith(' ') || rawLine.StartsWith('\t')) && unfoldedLines.Count > 0)
{
unfoldedLines[^1] += rawLine.TrimStart(' ', '\t');
continue;
}
unfoldedLines.Add(rawLine);
}
return string.Join("\n", unfoldedLines);
}
private static string TrimCommonIndentation(string value)
{
var normalizedValue = value
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
var lines = normalizedValue.Split('\n');
var nonEmptyLines = lines
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToList();
if (nonEmptyLines.Count == 0)
return normalizedValue;
var commonIndentation = nonEmptyLines
.Select(line => line.TakeWhile(ch => ch is ' ' or '\t').Count())
.Min();
if (commonIndentation <= 0)
return normalizedValue;
return string.Join(
"\n",
lines.Select(line =>
{
if (string.IsNullOrWhiteSpace(line))
return string.Empty;
return line.Length >= commonIndentation
? line[commonIndentation..]
: line.TrimStart(' ', '\t');
}));
}
private static string NormalizeCalendarColor(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var color = value.Trim();
if (color.StartsWith('#'))
{
color = color[1..];
}
if (color.Length == 8)
{
color = color[..6];
}
else if (color.Length == 3)
{
color = string.Concat(color.Select(c => $"{c}{c}"));
}
if (color.Length != 6 || !int.TryParse(color, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out _))
return string.Empty;
return $"#{color.ToUpperInvariant()}";
}
private static Uri CreateAbsoluteUri(Uri baseUri, string href)
{
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
@@ -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;
}
}