From d66015bebdd69b28aa5e8b01003a856b8c91bb3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Wed, 22 Apr 2026 09:55:13 +0200 Subject: [PATCH] Fixed iCloud/Yahoo special providers and implemented more calendar metadata to support calendar colors in CalDav synchronizer. --- .../Models/Calendar/CalDavCalendar.cs | 9 + .../SpecialImapProviderConfigResolverTests.cs | 41 +++ .../CalDavCalendarMetadataTests.cs | 262 ++++++++++++++++++ Wino.Core/Synchronizers/ImapSynchronizer.cs | 60 +++- .../ImapCalDavSettingsPageViewModel.cs | 18 +- .../SpecialImapCredentialsPageViewModel.cs | 2 +- Wino.Services/CalDavClient.cs | 186 ++++++++++++- .../SpecialImapProviderConfigResolver.cs | 21 +- 8 files changed, 585 insertions(+), 14 deletions(-) create mode 100644 Wino.Core.Tests/Services/SpecialImapProviderConfigResolverTests.cs create mode 100644 Wino.Core.Tests/Synchronizers/CalDavCalendarMetadataTests.cs 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; + } }