Import functionality for wino accounts, calendar sync UI, bunch of shell improvements
This commit is contained in:
@@ -44,6 +44,7 @@ public static class CoreContainerSetup
|
||||
services.AddTransient<OutlookRateLimitHandler>();
|
||||
|
||||
// Register Gmail error handlers
|
||||
services.AddTransient<GmailAuthenticationFailedHandler>();
|
||||
services.AddTransient<GmailQuotaExceededHandler>();
|
||||
services.AddTransient<GmailRateLimitHandler>();
|
||||
services.AddTransient<GmailHistoryExpiredHandler>();
|
||||
@@ -56,6 +57,9 @@ public static class CoreContainerSetup
|
||||
services.AddTransient<ImapFolderNotFoundHandler>();
|
||||
services.AddTransient<ImapProtocolErrorHandler>();
|
||||
|
||||
// Register Outlook auth handlers
|
||||
services.AddTransient<OutlookAuthenticationFailedHandler>();
|
||||
|
||||
// Register error handler factories
|
||||
services.AddTransient<IOutlookSynchronizerErrorHandlerFactory, OutlookSynchronizerErrorHandlingFactory>();
|
||||
services.AddTransient<IGmailSynchronizerErrorHandlerFactory, GmailSynchronizerErrorHandlingFactory>();
|
||||
|
||||
@@ -147,10 +147,8 @@ public static class GoogleIntegratorExtensions
|
||||
// Bg color must present. Generate one if doesnt exists.
|
||||
// Text color is optional. It'll be overriden by UI for readibility.
|
||||
|
||||
calendar.BackgroundColorHex = string.IsNullOrEmpty(calendarListEntry.BackgroundColor)
|
||||
? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex()
|
||||
: calendarListEntry.BackgroundColor;
|
||||
calendar.TextColorHex = string.IsNullOrEmpty(calendarListEntry.ForegroundColor) ? "#000000" : calendarListEntry.ForegroundColor;
|
||||
calendar.BackgroundColorHex = fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex();
|
||||
calendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(calendar.BackgroundColorHex);
|
||||
|
||||
return calendar;
|
||||
}
|
||||
|
||||
@@ -191,10 +191,8 @@ public static class OutlookIntegratorExtensions
|
||||
// Bg must be present. Generate flat one if doesn't exists.
|
||||
// Text doesnt exists for Outlook.
|
||||
|
||||
calendar.BackgroundColorHex = string.IsNullOrEmpty(outlookCalendar.HexColor)
|
||||
? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex()
|
||||
: outlookCalendar.HexColor;
|
||||
calendar.TextColorHex = "#000000";
|
||||
calendar.BackgroundColorHex = fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex();
|
||||
calendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(calendar.BackgroundColorHex);
|
||||
|
||||
return calendar;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain.Misc;
|
||||
|
||||
@@ -12,9 +13,21 @@ public static class ColorHelpers
|
||||
|
||||
public static string GenerateFlatColorHex() => GetDistinctFlatColorHex(Array.Empty<string>());
|
||||
|
||||
public static string GetDistinctFlatColorHex(IEnumerable<string> usedColors)
|
||||
public static string GetDistinctFlatColorHex(IEnumerable<string> usedColors, string preferredColor = null)
|
||||
{
|
||||
var palette = CalendarColorPalette.GetColors();
|
||||
var normalizedUsedColors = usedColors?
|
||||
.Select(NormalizeHexColor)
|
||||
.Where(color => !string.IsNullOrWhiteSpace(color))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (TryNormalizeHexColor(preferredColor, out var normalizedPreferred) &&
|
||||
palette.Contains(normalizedPreferred, StringComparer.OrdinalIgnoreCase) &&
|
||||
!normalizedUsedColors.Contains(normalizedPreferred))
|
||||
{
|
||||
return normalizedPreferred;
|
||||
}
|
||||
|
||||
var distinctColor = CalendarColorPalette.GetDistinctColor(usedColors);
|
||||
if (palette.Contains(distinctColor))
|
||||
{
|
||||
@@ -26,6 +39,18 @@ public static class ColorHelpers
|
||||
return candidate;
|
||||
}
|
||||
|
||||
public static string GetReadableTextColorHex(string backgroundColor)
|
||||
{
|
||||
if (!TryNormalizeHexColor(backgroundColor, out var normalizedColor))
|
||||
{
|
||||
return "#FFFFFF";
|
||||
}
|
||||
|
||||
var color = ColorTranslator.FromHtml(normalizedColor);
|
||||
var luminance = ((0.299 * color.R) + (0.587 * color.G) + (0.114 * color.B)) / 255d;
|
||||
return luminance > 0.6 ? "#111111" : "#FFFFFF";
|
||||
}
|
||||
|
||||
public static string ToHexString(this Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
|
||||
|
||||
public static string ToRgbString(this Color c) => $"RGB({c.R}, {c.G}, {c.B})";
|
||||
@@ -41,4 +66,35 @@ public static class ColorHelpers
|
||||
|
||||
return adjusted.ToHexString();
|
||||
}
|
||||
|
||||
private static bool TryNormalizeHexColor(string value, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var color = value.Trim();
|
||||
if (color.StartsWith('#'))
|
||||
{
|
||||
color = color[1..];
|
||||
}
|
||||
|
||||
if (color.Length != 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(color, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = $"#{color.ToUpperInvariant()}";
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeHexColor(string value)
|
||||
=> TryNormalizeHexColor(value, out var normalized) ? normalized : string.Empty;
|
||||
}
|
||||
|
||||
@@ -11,12 +11,14 @@ namespace Wino.Core.Services;
|
||||
public class GmailSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IGmailSynchronizerErrorHandlerFactory
|
||||
{
|
||||
public GmailSynchronizerErrorHandlingFactory(
|
||||
GmailAuthenticationFailedHandler authenticationFailedHandler,
|
||||
GmailQuotaExceededHandler quotaExceededHandler,
|
||||
GmailRateLimitHandler rateLimitHandler,
|
||||
GmailHistoryExpiredHandler historyExpiredHandler,
|
||||
EntityNotFoundHandler entityNotFoundHandler)
|
||||
{
|
||||
// Order matters - more specific handlers should be registered first
|
||||
RegisterHandler(authenticationFailedHandler);
|
||||
RegisterHandler(quotaExceededHandler);
|
||||
RegisterHandler(historyExpiredHandler);
|
||||
RegisterHandler(entityNotFoundHandler);
|
||||
|
||||
@@ -6,11 +6,13 @@ namespace Wino.Core.Services;
|
||||
|
||||
public class OutlookSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IOutlookSynchronizerErrorHandlerFactory
|
||||
{
|
||||
public OutlookSynchronizerErrorHandlingFactory(ObjectCannotBeDeletedHandler objectCannotBeDeleted,
|
||||
public OutlookSynchronizerErrorHandlingFactory(OutlookAuthenticationFailedHandler authenticationFailedHandler,
|
||||
ObjectCannotBeDeletedHandler objectCannotBeDeleted,
|
||||
EntityNotFoundHandler entityNotFoundHandler,
|
||||
DeltaTokenExpiredHandler deltaTokenExpiredHandler,
|
||||
OutlookRateLimitHandler outlookRateLimitHandler)
|
||||
{
|
||||
RegisterHandler(authenticationFailedHandler);
|
||||
RegisterHandler(outlookRateLimitHandler);
|
||||
RegisterHandler(objectCannotBeDeleted);
|
||||
RegisterHandler(entityNotFoundHandler);
|
||||
|
||||
@@ -4,7 +4,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
@@ -13,6 +15,7 @@ using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Authentication;
|
||||
using Wino.Core.Domain.Models.Connectivity;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Messaging.UI;
|
||||
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
@@ -27,6 +30,7 @@ public class SynchronizationManager : ISynchronizationManager
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, IWinoSynchronizerBase> _synchronizerCache = new();
|
||||
private readonly ConcurrentDictionary<Guid, CancellationTokenSource> _accountSynchronizationCancellationSources = new();
|
||||
private readonly ConcurrentDictionary<Guid, SemaphoreSlim> _calendarSynchronizationLocks = new();
|
||||
private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
|
||||
private readonly ILogger _logger = Log.ForContext<SynchronizationManager>();
|
||||
|
||||
@@ -131,6 +135,12 @@ public class SynchronizationManager : ISynchronizationManager
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
if (await IsSynchronizationBlockedByAttentionAsync(options.AccountId).ConfigureAwait(false))
|
||||
{
|
||||
_logger.Information("Skipping mail synchronization for account {AccountId} because it requires credential attention.", options.AccountId);
|
||||
return MailSynchronizationResult.Canceled;
|
||||
}
|
||||
|
||||
var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId);
|
||||
if (synchronizer == null)
|
||||
{
|
||||
@@ -170,7 +180,8 @@ public class SynchronizationManager : ISynchronizationManager
|
||||
catch (AuthenticationAttentionException authEx)
|
||||
{
|
||||
_logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId);
|
||||
|
||||
await SetInvalidCredentialAttentionAsync(authEx.Account).ConfigureAwait(false);
|
||||
|
||||
// Create app notification for authentication attention
|
||||
_notificationBuilder.CreateAttentionRequiredNotification(authEx.Account);
|
||||
|
||||
@@ -348,9 +359,75 @@ public class SynchronizationManager : ISynchronizationManager
|
||||
/// <returns>Synchronization result</returns>
|
||||
public async Task<CalendarSynchronizationResult> SynchronizeCalendarAsync(CalendarSynchronizationOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> options.Type == CalendarSynchronizationType.Strict
|
||||
? await SynchronizeCalendarStrictAsync(options, cancellationToken).ConfigureAwait(false)
|
||||
: await RunCalendarSynchronizationWithLockAsync(
|
||||
options.AccountId,
|
||||
cancellationToken,
|
||||
() => SynchronizeCalendarCoreAsync(options, cancellationToken, reportState: true)).ConfigureAwait(false);
|
||||
|
||||
private async Task<CalendarSynchronizationResult> SynchronizeCalendarStrictAsync(
|
||||
CalendarSynchronizationOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadataOptions = new CalendarSynchronizationOptions
|
||||
{
|
||||
AccountId = options.AccountId,
|
||||
Type = CalendarSynchronizationType.CalendarMetadata,
|
||||
SynchronizationCalendarIds = options.SynchronizationCalendarIds
|
||||
};
|
||||
|
||||
var eventOptions = new CalendarSynchronizationOptions
|
||||
{
|
||||
AccountId = options.AccountId,
|
||||
Type = CalendarSynchronizationType.CalendarEvents,
|
||||
SynchronizationCalendarIds = options.SynchronizationCalendarIds
|
||||
};
|
||||
|
||||
return await RunCalendarSynchronizationWithLockAsync(options.AccountId, cancellationToken, async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
PublishCalendarSynchronizationState(
|
||||
options.AccountId,
|
||||
CalendarSynchronizationType.Strict,
|
||||
isSynchronizationInProgress: true,
|
||||
Translator.SyncAction_SynchronizingCalendarMetadata);
|
||||
|
||||
var metadataResult = await SynchronizeCalendarCoreAsync(metadataOptions, cancellationToken, reportState: false).ConfigureAwait(false);
|
||||
if (metadataResult.CompletedState is SynchronizationCompletedState.Failed or SynchronizationCompletedState.Canceled)
|
||||
{
|
||||
return metadataResult;
|
||||
}
|
||||
|
||||
PublishCalendarSynchronizationState(
|
||||
options.AccountId,
|
||||
CalendarSynchronizationType.Strict,
|
||||
isSynchronizationInProgress: true,
|
||||
Translator.SyncAction_SynchronizingCalendarEvents);
|
||||
|
||||
return await SynchronizeCalendarCoreAsync(eventOptions, cancellationToken, reportState: false).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PublishCalendarSynchronizationState(options.AccountId, CalendarSynchronizationType.Strict, isSynchronizationInProgress: false);
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<CalendarSynchronizationResult> SynchronizeCalendarCoreAsync(
|
||||
CalendarSynchronizationOptions options,
|
||||
CancellationToken cancellationToken,
|
||||
bool reportState)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
if (await IsSynchronizationBlockedByAttentionAsync(options.AccountId).ConfigureAwait(false))
|
||||
{
|
||||
_logger.Information("Skipping calendar synchronization for account {AccountId} because it requires credential attention.", options.AccountId);
|
||||
return CalendarSynchronizationResult.Canceled;
|
||||
}
|
||||
|
||||
var synchronizer = await GetOrCreateSynchronizerAsync(options.AccountId);
|
||||
if (synchronizer == null)
|
||||
{
|
||||
@@ -361,6 +438,15 @@ public class SynchronizationManager : ISynchronizationManager
|
||||
_logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}",
|
||||
options.AccountId, options.Type);
|
||||
|
||||
if (reportState)
|
||||
{
|
||||
PublishCalendarSynchronizationState(
|
||||
options.AccountId,
|
||||
options.Type,
|
||||
isSynchronizationInProgress: true,
|
||||
GetCalendarSynchronizationStatus(options.Type));
|
||||
}
|
||||
|
||||
var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource());
|
||||
using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
@@ -387,7 +473,8 @@ public class SynchronizationManager : ISynchronizationManager
|
||||
catch (AuthenticationAttentionException authEx)
|
||||
{
|
||||
_logger.Warning("Account {AccountId} requires attention due to authentication issues", options.AccountId);
|
||||
|
||||
await SetInvalidCredentialAttentionAsync(authEx.Account).ConfigureAwait(false);
|
||||
|
||||
// Create app notification for authentication attention
|
||||
_notificationBuilder.CreateAttentionRequiredNotification(authEx.Account);
|
||||
|
||||
@@ -398,6 +485,13 @@ public class SynchronizationManager : ISynchronizationManager
|
||||
_logger.Error(ex, "Calendar synchronization failed for account {AccountId}", options.AccountId);
|
||||
return CalendarSynchronizationResult.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (reportState)
|
||||
{
|
||||
PublishCalendarSynchronizationState(options.AccountId, options.Type, isSynchronizationInProgress: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -667,4 +761,69 @@ public class SynchronizationManager : ISynchronizationManager
|
||||
throw new InvalidOperationException("SynchronizationManager must be initialized before use. Call InitializeAsync first.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetInvalidCredentialAttentionAsync(MailAccount account)
|
||||
{
|
||||
if (account == null || _accountService == null)
|
||||
return;
|
||||
|
||||
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
|
||||
|
||||
if (persistedAccount == null)
|
||||
return;
|
||||
|
||||
if (persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
|
||||
return;
|
||||
|
||||
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
|
||||
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> IsSynchronizationBlockedByAttentionAsync(Guid accountId)
|
||||
{
|
||||
if (_accountService == null)
|
||||
return false;
|
||||
|
||||
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
|
||||
return account?.AttentionReason == AccountAttentionReason.InvalidCredentials;
|
||||
}
|
||||
|
||||
private void PublishCalendarSynchronizationState(
|
||||
Guid accountId,
|
||||
CalendarSynchronizationType synchronizationType,
|
||||
bool isSynchronizationInProgress,
|
||||
string synchronizationStatus = "")
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new AccountCalendarSynchronizationStateChanged(
|
||||
accountId,
|
||||
synchronizationType,
|
||||
isSynchronizationInProgress,
|
||||
synchronizationStatus));
|
||||
}
|
||||
|
||||
private static string GetCalendarSynchronizationStatus(CalendarSynchronizationType synchronizationType)
|
||||
=> synchronizationType switch
|
||||
{
|
||||
CalendarSynchronizationType.CalendarMetadata => Translator.SyncAction_SynchronizingCalendarMetadata,
|
||||
CalendarSynchronizationType.Strict => Translator.SyncAction_SynchronizingCalendarData,
|
||||
_ => Translator.SyncAction_SynchronizingCalendarEvents
|
||||
};
|
||||
|
||||
private async Task<CalendarSynchronizationResult> RunCalendarSynchronizationWithLockAsync(
|
||||
Guid accountId,
|
||||
CancellationToken cancellationToken,
|
||||
Func<Task<CalendarSynchronizationResult>> synchronizationFactory)
|
||||
{
|
||||
var calendarSemaphore = _calendarSynchronizationLocks.GetOrAdd(accountId, _ => new SemaphoreSlim(1, 1));
|
||||
await calendarSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
return await synchronizationFactory().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
calendarSemaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
public class GmailAuthenticationFailedHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailAuthenticationFailedHandler>();
|
||||
private readonly IAccountService _accountService;
|
||||
|
||||
public GmailAuthenticationFailedHandler(IAccountService accountService)
|
||||
{
|
||||
_accountService = accountService;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
if (error.Exception is not GoogleApiException googleEx)
|
||||
return false;
|
||||
|
||||
var reason = googleEx.Error?.Errors?.FirstOrDefault()?.Reason?.ToLowerInvariant() ?? string.Empty;
|
||||
var message = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
return googleEx.HttpStatusCode == HttpStatusCode.Unauthorized ||
|
||||
(googleEx.HttpStatusCode == HttpStatusCode.Forbidden &&
|
||||
(reason.Contains("auth") ||
|
||||
reason.Contains("credential") ||
|
||||
message.Contains("invalid credentials") ||
|
||||
message.Contains("insufficient authentication") ||
|
||||
message.Contains("login required")));
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail authentication failed for account {AccountName} ({AccountId}). User intervention is required.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
if (error.Account != null)
|
||||
{
|
||||
await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
error.Severity = SynchronizerErrorSeverity.AuthRequired;
|
||||
error.Category = SynchronizerErrorCategory.Authentication;
|
||||
error.RetryDelay = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task PersistInvalidCredentialAttentionAsync(MailAccount account)
|
||||
{
|
||||
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
|
||||
|
||||
if (persistedAccount == null || persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
|
||||
return;
|
||||
|
||||
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
|
||||
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Security;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
@@ -14,6 +15,12 @@ namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapAuthenticationFailedHandler>();
|
||||
private readonly IAccountService _accountService;
|
||||
|
||||
public ImapAuthenticationFailedHandler(IAccountService accountService)
|
||||
{
|
||||
_accountService = accountService;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
@@ -22,12 +29,17 @@ public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
|
||||
(error.ErrorMessage?.Contains("authentication", System.StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP authentication failed for account {AccountName} ({AccountId}). User needs to re-authenticate.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
if (error.Account != null)
|
||||
{
|
||||
await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Mark as requiring authentication - this will stop sync and notify user
|
||||
error.Severity = SynchronizerErrorSeverity.AuthRequired;
|
||||
error.Category = SynchronizerErrorCategory.Authentication;
|
||||
@@ -35,6 +47,20 @@ public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
|
||||
// No point in retrying auth failures - credentials need to be updated
|
||||
error.RetryDelay = null;
|
||||
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task PersistInvalidCredentialAttentionAsync(MailAccount account)
|
||||
{
|
||||
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
|
||||
|
||||
if (persistedAccount == null)
|
||||
return;
|
||||
|
||||
if (persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
|
||||
return;
|
||||
|
||||
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
|
||||
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Graph.Models.ODataErrors;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||
|
||||
public class OutlookAuthenticationFailedHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<OutlookAuthenticationFailedHandler>();
|
||||
private readonly IAccountService _accountService;
|
||||
|
||||
public OutlookAuthenticationFailedHandler(IAccountService accountService)
|
||||
{
|
||||
_accountService = accountService;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
if (error.Exception is ApiException apiException)
|
||||
{
|
||||
if (apiException.ResponseStatusCode == 401)
|
||||
return true;
|
||||
|
||||
if (apiException.ResponseStatusCode == 403)
|
||||
{
|
||||
var message = apiException.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
return message.Contains("access denied") || message.Contains("authentication");
|
||||
}
|
||||
}
|
||||
|
||||
if (error.Exception is ODataError oDataError)
|
||||
{
|
||||
if (oDataError.ResponseStatusCode == 401)
|
||||
return true;
|
||||
|
||||
var code = oDataError.Error?.Code?.ToLowerInvariant() ?? string.Empty;
|
||||
var message = oDataError.Error?.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
return code.Contains("invalidauthenticationtoken") ||
|
||||
code.Contains("invalidgrant") ||
|
||||
code.Contains("token") ||
|
||||
message.Contains("access token") ||
|
||||
message.Contains("authentication");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Outlook authentication failed for account {AccountName} ({AccountId}). User intervention is required.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
if (error.Account != null)
|
||||
{
|
||||
await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
error.Severity = SynchronizerErrorSeverity.AuthRequired;
|
||||
error.Category = SynchronizerErrorCategory.Authentication;
|
||||
error.RetryDelay = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task PersistInvalidCredentialAttentionAsync(MailAccount account)
|
||||
{
|
||||
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
|
||||
|
||||
if (persistedAccount == null || persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
|
||||
return;
|
||||
|
||||
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
|
||||
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -603,11 +603,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
var localCalendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
|
||||
var remotePrimaryCalendarId = GetPrimaryCalendarId(calendarListResponse.Items);
|
||||
var usedCalendarColors = new HashSet<string>(
|
||||
localCalendars
|
||||
.Select(a => a.BackgroundColorHex)
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<AccountCalendar> insertedCalendars = new();
|
||||
List<AccountCalendar> updatedCalendars = new();
|
||||
@@ -637,25 +633,25 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
if (existingLocalCalendar == null)
|
||||
{
|
||||
// Insert new calendar.
|
||||
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
|
||||
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, calendar.BackgroundColor);
|
||||
var localCalendar = calendar.AsCalendar(Account.Id, fallbackColor);
|
||||
localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(localCalendar.BackgroundColorHex) || usedCalendarColors.Contains(localCalendar.BackgroundColorHex))
|
||||
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
|
||||
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, localCalendar.BackgroundColorHex);
|
||||
localCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(localCalendar.BackgroundColorHex);
|
||||
usedCalendarColors.Add(localCalendar.BackgroundColorHex);
|
||||
insertedCalendars.Add(localCalendar);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing calendar. Right now we only update the name.
|
||||
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId))
|
||||
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocalCalendar.BackgroundColorHex);
|
||||
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId) ||
|
||||
!string.Equals(existingLocalCalendar.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
existingLocalCalendar.Name = calendar.Summary;
|
||||
existingLocalCalendar.TimeZone = calendar.TimeZone;
|
||||
if (!string.IsNullOrEmpty(calendar.BackgroundColor))
|
||||
existingLocalCalendar.BackgroundColorHex = calendar.BackgroundColor;
|
||||
if (!string.IsNullOrEmpty(calendar.ForegroundColor))
|
||||
existingLocalCalendar.TextColorHex = calendar.ForegroundColor;
|
||||
existingLocalCalendar.BackgroundColorHex = resolvedColor;
|
||||
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
|
||||
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
updatedCalendars.Add(existingLocalCalendar);
|
||||
@@ -665,6 +661,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
// Remove it from the local folder list to skip additional calendar updates.
|
||||
localCalendars.Remove(existingLocalCalendar);
|
||||
}
|
||||
|
||||
usedCalendarColors.Add(resolvedColor);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1455,11 +1455,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
var remoteCalendarsById = remoteCalendars
|
||||
.GroupBy(c => c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var usedCalendarColors = new HashSet<string>(
|
||||
localCalendars
|
||||
.Select(a => a.BackgroundColorHex)
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId;
|
||||
|
||||
@@ -1493,25 +1489,33 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
IsPrimary = isPrimary,
|
||||
IsSynchronizationEnabled = true,
|
||||
IsExtended = true,
|
||||
TextColorHex = "#000000",
|
||||
BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors),
|
||||
TimeZone = "UTC",
|
||||
SynchronizationDeltaToken = string.Empty
|
||||
};
|
||||
|
||||
newCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(newCalendar.BackgroundColorHex);
|
||||
usedCalendarColors.Add(newCalendar.BackgroundColorHex);
|
||||
await _imapChangeProcessor.InsertAccountCalendarAsync(newCalendar).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocal.BackgroundColorHex);
|
||||
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|
||||
|| existingLocal.IsPrimary != isPrimary;
|
||||
|| existingLocal.IsPrimary != isPrimary
|
||||
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!shouldUpdate)
|
||||
{
|
||||
usedCalendarColors.Add(resolvedColor);
|
||||
continue;
|
||||
}
|
||||
|
||||
existingLocal.Name = remoteCalendar.Name;
|
||||
existingLocal.IsPrimary = isPrimary;
|
||||
existingLocal.BackgroundColorHex = resolvedColor;
|
||||
existingLocal.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocal.BackgroundColorHex);
|
||||
usedCalendarColors.Add(existingLocal.BackgroundColorHex);
|
||||
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2333,11 +2333,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
var remotePrimaryCalendarId = await GetPrimaryCalendarIdAsync(calendars.Value, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var localCalendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
|
||||
var usedCalendarColors = new HashSet<string>(
|
||||
localCalendars
|
||||
.Select(a => a.BackgroundColorHex)
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<AccountCalendar> insertedCalendars = new();
|
||||
List<AccountCalendar> updatedCalendars = new();
|
||||
@@ -2367,23 +2363,25 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
if (existingLocalCalendar == null)
|
||||
{
|
||||
// Insert new calendar.
|
||||
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
|
||||
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, calendar.HexColor);
|
||||
var localCalendar = calendar.AsCalendar(Account, fallbackColor);
|
||||
localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(localCalendar.BackgroundColorHex) || usedCalendarColors.Contains(localCalendar.BackgroundColorHex))
|
||||
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
|
||||
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, localCalendar.BackgroundColorHex);
|
||||
localCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(localCalendar.BackgroundColorHex);
|
||||
usedCalendarColors.Add(localCalendar.BackgroundColorHex);
|
||||
insertedCalendars.Add(localCalendar);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing calendar. Right now we only update the name.
|
||||
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId))
|
||||
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocalCalendar.BackgroundColorHex);
|
||||
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId) ||
|
||||
!string.Equals(existingLocalCalendar.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
existingLocalCalendar.Name = calendar.Name;
|
||||
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrEmpty(calendar.HexColor))
|
||||
existingLocalCalendar.BackgroundColorHex = calendar.HexColor;
|
||||
existingLocalCalendar.BackgroundColorHex = resolvedColor;
|
||||
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
|
||||
|
||||
updatedCalendars.Add(existingLocalCalendar);
|
||||
}
|
||||
@@ -2392,6 +2390,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
// Remove it from the local folder list to skip additional calendar updates.
|
||||
localCalendars.Remove(existingLocalCalendar);
|
||||
}
|
||||
|
||||
usedCalendarColors.Add(resolvedColor);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user