Merged feature/vNext. Initial commit for Wino Mail 2.0
This commit is contained in:
@@ -4,6 +4,9 @@ using Wino.Authentication;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration.Processors;
|
||||
using Wino.Core.Services;
|
||||
using Wino.Core.Synchronizers.Errors;
|
||||
using Wino.Core.Synchronizers.Errors.Gmail;
|
||||
using Wino.Core.Synchronizers.Errors.Imap;
|
||||
using Wino.Core.Synchronizers.Errors.Outlook;
|
||||
using Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
@@ -17,6 +20,8 @@ public static class CoreContainerSetup
|
||||
|
||||
services.AddSingleton(loggerLevelSwitcher);
|
||||
services.AddSingleton<ISynchronizerFactory, SynchronizerFactory>();
|
||||
services.AddSingleton<ISynchronizationManager>(provider => SynchronizationManager.Instance);
|
||||
services.AddTransient<SynchronizationManagerInitializer>();
|
||||
|
||||
services.AddTransient<IGmailChangeProcessor, GmailChangeProcessor>();
|
||||
services.AddTransient<IImapChangeProcessor, ImapChangeProcessor>();
|
||||
@@ -31,15 +36,36 @@ public static class CoreContainerSetup
|
||||
services.AddTransient<IOutlookAuthenticator, OutlookAuthenticator>();
|
||||
services.AddTransient<IGmailAuthenticator, GmailAuthenticator>();
|
||||
|
||||
services.AddTransient<IImapSynchronizationStrategyProvider, ImapSynchronizationStrategyProvider>();
|
||||
services.AddTransient<CondstoreSynchronizer>();
|
||||
services.AddTransient<QResyncSynchronizer>();
|
||||
services.AddTransient<UidBasedSynchronizer>();
|
||||
services.AddTransient<UnifiedImapSynchronizer>();
|
||||
|
||||
// Register error factory handlers
|
||||
// Register Outlook error handlers
|
||||
services.AddTransient<ObjectCannotBeDeletedHandler>();
|
||||
services.AddTransient<DeltaTokenExpiredHandler>();
|
||||
services.AddTransient<OutlookRateLimitHandler>();
|
||||
|
||||
// Register Gmail error handlers
|
||||
services.AddTransient<GmailAuthenticationFailedHandler>();
|
||||
services.AddTransient<GmailQuotaExceededHandler>();
|
||||
services.AddTransient<GmailRateLimitHandler>();
|
||||
services.AddTransient<GmailHistoryExpiredHandler>();
|
||||
// Register shared error handlers
|
||||
services.AddTransient<EntityNotFoundHandler>();
|
||||
|
||||
// Register IMAP error handlers
|
||||
services.AddTransient<ImapConnectionLostHandler>();
|
||||
services.AddTransient<ImapAuthenticationFailedHandler>();
|
||||
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>();
|
||||
services.AddTransient<IImapSynchronizerErrorHandlerFactory, ImapSynchronizerErrorHandlingFactory>();
|
||||
|
||||
// Register retry executor
|
||||
services.AddTransient<IRetryExecutor, RetryExecutor>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
|
||||
@@ -23,10 +23,6 @@ public interface ISynchronizerErrorHandler
|
||||
Task<bool> HandleAsync(SynchronizerErrorContext error);
|
||||
}
|
||||
|
||||
public interface ISynchronizerErrorHandlerFactory
|
||||
{
|
||||
Task<bool> HandleErrorAsync(SynchronizerErrorContext error);
|
||||
}
|
||||
|
||||
public interface IOutlookSynchronizerErrorHandlerFactory : ISynchronizerErrorHandlerFactory;
|
||||
public interface IGmailSynchronizerErrorHandlerFactory : ISynchronizerErrorHandlerFactory;
|
||||
public interface IImapSynchronizerErrorHandlerFactory : ISynchronizerErrorHandlerFactory;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Domain.Models.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Contains context information about a synchronizer error
|
||||
/// </summary>
|
||||
public class SynchronizerErrorContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Account associated with the error
|
||||
/// </summary>
|
||||
public MailAccount Account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error code
|
||||
/// </summary>
|
||||
public int? ErrorCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the error message
|
||||
/// </summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the request bundle associated with the error
|
||||
/// </summary>
|
||||
public IRequestBundle RequestBundle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional data associated with the error
|
||||
/// </summary>
|
||||
public Dictionary<string, object> AdditionalData { get; set; } = new Dictionary<string, object>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the exception associated with the error
|
||||
/// </summary>
|
||||
public Exception Exception { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using MimeKit;
|
||||
|
||||
namespace Wino.Core.Extensions;
|
||||
|
||||
public static class CalendarInvitationExtensions
|
||||
{
|
||||
public static string ExtractInvitationUid(this MimeMessage message)
|
||||
{
|
||||
if (message == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var icsContent = GetCalendarContent(message);
|
||||
if (string.IsNullOrWhiteSpace(icsContent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var unfolded = UnfoldIcs(icsContent);
|
||||
var veventSection = ExtractFirstVEventSection(unfolded);
|
||||
if (string.IsNullOrWhiteSpace(veventSection))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return TryReadIcsProperty(veventSection, "UID", out var uid)
|
||||
? uid
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string GetCalendarContent(MimeMessage message)
|
||||
{
|
||||
var textPart = message.BodyParts
|
||||
.OfType<TextPart>()
|
||||
.FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (textPart != null)
|
||||
{
|
||||
return textPart.Text;
|
||||
}
|
||||
|
||||
var mimePart = message.BodyParts
|
||||
.OfType<MimePart>()
|
||||
.FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (mimePart == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
mimePart.Content.DecodeTo(stream);
|
||||
var bytes = stream.ToArray();
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var charset = mimePart.ContentType?.Charset;
|
||||
var encoding = string.IsNullOrWhiteSpace(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset);
|
||||
return encoding.GetString(bytes);
|
||||
}
|
||||
|
||||
private static string UnfoldIcs(string content)
|
||||
=> content
|
||||
.Replace("\r\n ", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\r\n\t", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n ", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n\t", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
private static string ExtractFirstVEventSection(string ics)
|
||||
{
|
||||
const string beginVevent = "BEGIN:VEVENT";
|
||||
const string endVevent = "END:VEVENT";
|
||||
|
||||
var beginIndex = ics.IndexOf(beginVevent, StringComparison.OrdinalIgnoreCase);
|
||||
if (beginIndex < 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var endIndex = ics.IndexOf(endVevent, beginIndex, StringComparison.OrdinalIgnoreCase);
|
||||
if (endIndex < 0)
|
||||
{
|
||||
return ics[beginIndex..];
|
||||
}
|
||||
|
||||
return ics.Substring(beginIndex, endIndex - beginIndex + endVevent.Length);
|
||||
}
|
||||
|
||||
private static bool TryReadIcsProperty(string icsSection, string propertyName, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
var lines = icsSection.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (!line.StartsWith(propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var colonIndex = line.IndexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex >= line.Length - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
value = line[(colonIndex + 1)..].Trim();
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Web;
|
||||
using Google.Apis.Calendar.v3.Data;
|
||||
using Google.Apis.Gmail.v1.Data;
|
||||
using MimeKit;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Misc;
|
||||
using Wino.Services;
|
||||
using Wino.Services.Extensions;
|
||||
|
||||
namespace Wino.Core.Extensions;
|
||||
|
||||
@@ -121,41 +118,6 @@ public static class GoogleIntegratorExtensions
|
||||
return GetNormalizedLabelName(lastPart);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns MailCopy out of native Gmail message and converted MimeMessage of that native messaage.
|
||||
/// </summary>
|
||||
/// <param name="gmailMessage">Gmail Message</param>
|
||||
/// <param name="mimeMessage">MimeMessage representation of that native message.</param>
|
||||
/// <returns>MailCopy object that is ready to be inserted to database.</returns>
|
||||
public static MailCopy AsMailCopy(this Message gmailMessage, MimeMessage mimeMessage)
|
||||
{
|
||||
bool isUnread = gmailMessage.GetIsUnread();
|
||||
bool isFocused = gmailMessage.GetIsFocused();
|
||||
bool isFlagged = gmailMessage.GetIsFlagged();
|
||||
bool isDraft = gmailMessage.GetIsDraft();
|
||||
|
||||
return new MailCopy()
|
||||
{
|
||||
CreationDate = mimeMessage.Date.UtcDateTime,
|
||||
Subject = HttpUtility.HtmlDecode(mimeMessage.Subject),
|
||||
FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage),
|
||||
FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage),
|
||||
PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet),
|
||||
ThreadId = gmailMessage.ThreadId,
|
||||
Importance = (MailImportance)mimeMessage.Importance,
|
||||
Id = gmailMessage.Id,
|
||||
IsDraft = isDraft,
|
||||
HasAttachments = mimeMessage.Attachments.Any(),
|
||||
IsRead = !isUnread,
|
||||
IsFlagged = isFlagged,
|
||||
IsFocused = isFocused,
|
||||
InReplyTo = mimeMessage.InReplyTo,
|
||||
MessageId = mimeMessage.MessageId,
|
||||
References = mimeMessage.References.GetReferences(),
|
||||
FileId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
public static List<RemoteAccountAlias> GetRemoteAliases(this ListSendAsResponse response)
|
||||
{
|
||||
return response?.SendAs?.Select(a => new RemoteAccountAlias()
|
||||
@@ -169,7 +131,7 @@ public static class GoogleIntegratorExtensions
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public static AccountCalendar AsCalendar(this CalendarListEntry calendarListEntry, Guid accountId)
|
||||
public static AccountCalendar AsCalendar(this CalendarListEntry calendarListEntry, Guid accountId, string fallbackBackgroundColor = null)
|
||||
{
|
||||
var calendar = new AccountCalendar()
|
||||
{
|
||||
@@ -179,13 +141,14 @@ public static class GoogleIntegratorExtensions
|
||||
Id = Guid.NewGuid(),
|
||||
TimeZone = calendarListEntry.TimeZone,
|
||||
IsPrimary = calendarListEntry.Primary.GetValueOrDefault(),
|
||||
IsSynchronizationEnabled = true,
|
||||
};
|
||||
|
||||
// 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) ? 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;
|
||||
}
|
||||
@@ -215,6 +178,40 @@ public static class GoogleIntegratorExtensions
|
||||
return null;
|
||||
}
|
||||
|
||||
public static DateTime? GetEventLocalDateTime(EventDateTime calendarEvent)
|
||||
{
|
||||
if (calendarEvent == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (calendarEvent.DateTimeDateTimeOffset != null)
|
||||
{
|
||||
return DateTime.SpecifyKind(calendarEvent.DateTimeDateTimeOffset.Value.DateTime, DateTimeKind.Unspecified);
|
||||
}
|
||||
|
||||
if (calendarEvent.Date != null)
|
||||
{
|
||||
if (DateTime.TryParse(calendarEvent.Date, out DateTime eventDateTime))
|
||||
{
|
||||
return DateTime.SpecifyKind(eventDateTime, DateTimeKind.Unspecified);
|
||||
}
|
||||
|
||||
throw new Exception("Invalid date format in Google Calendar event date.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the timezone string from EventDateTime.
|
||||
/// Returns null for all-day events or if timezone is not specified.
|
||||
/// </summary>
|
||||
public static string GetEventTimeZone(EventDateTime eventDateTime)
|
||||
{
|
||||
return eventDateTime?.TimeZone;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RRULE, EXRULE, RDATE and EXDATE lines for a recurring event, as specified in RFC5545.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Graph.Models;
|
||||
@@ -8,6 +9,7 @@ using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Misc;
|
||||
|
||||
namespace Wino.Core.Extensions;
|
||||
@@ -60,16 +62,76 @@ public static class OutlookIntegratorExtensions
|
||||
FromName = outlookMessage.From?.EmailAddress?.Name,
|
||||
FromAddress = outlookMessage.From?.EmailAddress?.Address,
|
||||
Subject = outlookMessage.Subject,
|
||||
FileId = Guid.NewGuid()
|
||||
FileId = Guid.NewGuid(),
|
||||
ItemType = MailItemType.Mail // ItemType will be set by caller if calendar access is granted
|
||||
};
|
||||
|
||||
// Extract In-Reply-To and References from InternetMessageHeaders for threading.
|
||||
if (outlookMessage.InternetMessageHeaders != null)
|
||||
{
|
||||
var inReplyToHeader = outlookMessage.InternetMessageHeaders
|
||||
.FirstOrDefault(h => string.Equals(h.Name, "In-Reply-To", StringComparison.OrdinalIgnoreCase));
|
||||
if (inReplyToHeader != null)
|
||||
mailCopy.InReplyTo = MailHeaderExtensions.StripAngleBrackets(inReplyToHeader.Value);
|
||||
|
||||
var referencesHeader = outlookMessage.InternetMessageHeaders
|
||||
.FirstOrDefault(h => string.Equals(h.Name, "References", StringComparison.OrdinalIgnoreCase));
|
||||
if (referencesHeader != null)
|
||||
mailCopy.References = MailHeaderExtensions.NormalizeReferences(referencesHeader.Value);
|
||||
}
|
||||
|
||||
if (mailCopy.IsDraft)
|
||||
mailCopy.DraftId = mailCopy.ThreadId;
|
||||
|
||||
return mailCopy;
|
||||
}
|
||||
|
||||
public static Message AsOutlookMessage(this MimeMessage mime, bool includeInternetHeaders)
|
||||
public static MailItemType GetMailItemType(this Message message)
|
||||
{
|
||||
// Check if the message is an EventMessage (calendar-related)
|
||||
if (message is EventMessage eventMessage)
|
||||
{
|
||||
// Try to get MeetingMessageType from the property
|
||||
if (eventMessage.MeetingMessageType.HasValue)
|
||||
{
|
||||
return eventMessage.MeetingMessageType.Value switch
|
||||
{
|
||||
MeetingMessageType.MeetingRequest => MailItemType.CalendarInvitation,
|
||||
MeetingMessageType.MeetingCancelled => MailItemType.CalendarCancellation,
|
||||
MeetingMessageType.MeetingAccepted or
|
||||
MeetingMessageType.MeetingTenativelyAccepted or
|
||||
MeetingMessageType.MeetingDeclined => MailItemType.CalendarResponse,
|
||||
_ => MailItemType.Mail
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: Check @odata.type in AdditionalData to determine specific type
|
||||
if (message.AdditionalData?.TryGetValue("@odata.type", out var odataType) == true)
|
||||
{
|
||||
var odataTypeString = odataType?.ToString();
|
||||
if (odataTypeString != null)
|
||||
{
|
||||
// eventMessageRequest -> CalendarInvitation
|
||||
if (odataTypeString.Contains("eventMessageRequest", StringComparison.OrdinalIgnoreCase))
|
||||
return MailItemType.CalendarInvitation;
|
||||
|
||||
// eventMessageResponse -> CalendarResponse
|
||||
if (odataTypeString.Contains("eventMessageResponse", StringComparison.OrdinalIgnoreCase))
|
||||
return MailItemType.CalendarResponse;
|
||||
|
||||
// Generic eventMessage without specific type - assume invitation
|
||||
if (odataTypeString.Contains("eventMessage", StringComparison.OrdinalIgnoreCase))
|
||||
return MailItemType.CalendarInvitation;
|
||||
}
|
||||
}
|
||||
|
||||
return MailItemType.CalendarInvitation;
|
||||
}
|
||||
|
||||
return MailItemType.Mail;
|
||||
}
|
||||
|
||||
public static Message AsOutlookMessage(this MimeMessage mime, bool includeInternetHeaders, string conversationId = null)
|
||||
{
|
||||
var fromAddress = GetRecipients(mime.From).ElementAt(0);
|
||||
var toAddresses = GetRecipients(mime.To).ToList();
|
||||
@@ -77,35 +139,42 @@ public static class OutlookIntegratorExtensions
|
||||
var bccAddresses = GetRecipients(mime.Bcc).ToList();
|
||||
var replyToAddresses = GetRecipients(mime.ReplyTo).ToList();
|
||||
|
||||
// Prefer HTML body, fall back to plain text.
|
||||
var (bodyContent, bodyType) = mime.HtmlBody != null
|
||||
? (mime.HtmlBody, BodyType.Html)
|
||||
: (mime.TextBody ?? string.Empty, BodyType.Text);
|
||||
|
||||
var message = new Message()
|
||||
{
|
||||
Subject = mime.Subject,
|
||||
Importance = GetImportance(mime.Importance),
|
||||
Body = new ItemBody() { ContentType = BodyType.Html, Content = mime.HtmlBody },
|
||||
Body = new ItemBody() { ContentType = bodyType, Content = bodyContent },
|
||||
IsDraft = false,
|
||||
IsRead = true, // Sent messages are always read.
|
||||
IsRead = true,
|
||||
ToRecipients = toAddresses,
|
||||
CcRecipients = ccAddresses,
|
||||
BccRecipients = bccAddresses,
|
||||
From = fromAddress,
|
||||
InternetMessageId = GetProperId(mime.MessageId),
|
||||
InternetMessageId = mime.MessageId,
|
||||
ReplyTo = replyToAddresses,
|
||||
Attachments = []
|
||||
};
|
||||
|
||||
// Headers are only included when creating the draft.
|
||||
// When sending, they are not included. Graph will throw an error.
|
||||
if (!string.IsNullOrEmpty(conversationId))
|
||||
{
|
||||
message.ConversationId = conversationId;
|
||||
}
|
||||
|
||||
// Headers are only included when creating the draft.
|
||||
// Graph API throws an error if headers are included in send/patch operations.
|
||||
if (includeInternetHeaders)
|
||||
{
|
||||
message.InternetMessageHeaders = GetHeaderList(mime);
|
||||
}
|
||||
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
public static AccountCalendar AsCalendar(this Calendar outlookCalendar, MailAccount assignedAccount)
|
||||
public static AccountCalendar AsCalendar(this Calendar outlookCalendar, MailAccount assignedAccount, string fallbackBackgroundColor = null)
|
||||
{
|
||||
var calendar = new AccountCalendar()
|
||||
{
|
||||
@@ -114,6 +183,7 @@ public static class OutlookIntegratorExtensions
|
||||
RemoteCalendarId = outlookCalendar.Id,
|
||||
IsPrimary = outlookCalendar.IsDefaultCalendar.GetValueOrDefault(),
|
||||
Name = outlookCalendar.Name,
|
||||
IsSynchronizationEnabled = true,
|
||||
IsExtended = true,
|
||||
};
|
||||
|
||||
@@ -121,8 +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) ? ColorHelpers.GenerateFlatColorHex() : outlookCalendar.HexColor;
|
||||
calendar.TextColorHex = "#000000";
|
||||
calendar.BackgroundColorHex = fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex();
|
||||
calendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(calendar.BackgroundColorHex);
|
||||
|
||||
return calendar;
|
||||
}
|
||||
@@ -209,7 +279,9 @@ public static class OutlookIntegratorExtensions
|
||||
{
|
||||
if (recurrence.Range.Type == RecurrenceRangeType.EndDate && recurrence.Range.EndDate != null)
|
||||
{
|
||||
ruleBuilder.Append($"UNTIL={recurrence.Range.EndDate.Value:yyyyMMddTHHmmssZ};");
|
||||
// RFC 5545 requires YYYYMMDD or YYYYMMDDTHHMMSSinvalid format (no dashes or colons)
|
||||
var untilDate = recurrence.Range.EndDate.Value.DateTime.ToString("yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
|
||||
ruleBuilder.Append($"UNTIL={untilDate};");
|
||||
}
|
||||
else if (recurrence.Range.Type == RecurrenceRangeType.Numbered && recurrence.Range.NumberOfOccurrences.HasValue)
|
||||
{
|
||||
@@ -223,23 +295,37 @@ public static class OutlookIntegratorExtensions
|
||||
|
||||
public static DateTimeOffset GetDateTimeOffsetFromDateTimeTimeZone(DateTimeTimeZone dateTimeTimeZone)
|
||||
{
|
||||
if (dateTimeTimeZone == null || string.IsNullOrEmpty(dateTimeTimeZone.DateTime) || string.IsNullOrEmpty(dateTimeTimeZone.TimeZone))
|
||||
if (dateTimeTimeZone == null || string.IsNullOrEmpty(dateTimeTimeZone.DateTime))
|
||||
{
|
||||
throw new ArgumentException("DateTimeTimeZone is null or empty.");
|
||||
throw new ArgumentException("DateTimeTimeZone or DateTime is null or empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parse the DateTime string
|
||||
if (DateTime.TryParse(dateTimeTimeZone.DateTime, out DateTime parsedDateTime))
|
||||
if (!DateTime.TryParse(dateTimeTimeZone.DateTime, out DateTime parsedDateTime))
|
||||
{
|
||||
throw new ArgumentException("DateTime string is not in a valid format.");
|
||||
}
|
||||
|
||||
// If no timezone is provided, assume UTC
|
||||
if (string.IsNullOrEmpty(dateTimeTimeZone.TimeZone))
|
||||
{
|
||||
return new DateTimeOffset(parsedDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get TimeZoneInfo to get the offset
|
||||
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(dateTimeTimeZone.TimeZone);
|
||||
TimeSpan offset = timeZoneInfo.GetUtcOffset(parsedDateTime);
|
||||
return new DateTimeOffset(parsedDateTime, offset);
|
||||
}
|
||||
else
|
||||
throw new ArgumentException("DateTime string is not in a valid format.");
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
// If timezone is not found, assume UTC as fallback
|
||||
return new DateTimeOffset(parsedDateTime, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -247,6 +333,21 @@ public static class OutlookIntegratorExtensions
|
||||
}
|
||||
}
|
||||
|
||||
public static DateTime GetLocalDateTimeFromDateTimeTimeZone(DateTimeTimeZone dateTimeTimeZone)
|
||||
{
|
||||
if (dateTimeTimeZone == null || string.IsNullOrEmpty(dateTimeTimeZone.DateTime))
|
||||
{
|
||||
throw new ArgumentException("DateTimeTimeZone or DateTime is null or empty.");
|
||||
}
|
||||
|
||||
if (!DateTime.TryParse(dateTimeTimeZone.DateTime, out DateTime parsedDateTime))
|
||||
{
|
||||
throw new ArgumentException("DateTime string is not in a valid format.");
|
||||
}
|
||||
|
||||
return DateTime.SpecifyKind(parsedDateTime, DateTimeKind.Unspecified);
|
||||
}
|
||||
|
||||
private static AttendeeStatus GetAttendeeStatus(ResponseType? responseType)
|
||||
{
|
||||
return responseType switch
|
||||
@@ -261,9 +362,12 @@ public static class OutlookIntegratorExtensions
|
||||
};
|
||||
}
|
||||
|
||||
public static CalendarEventAttendee CreateAttendee(this Attendee attendee, Guid calendarItemId)
|
||||
public static CalendarEventAttendee CreateAttendee(this Attendee attendee, Guid calendarItemId, string organizerEmail = null)
|
||||
{
|
||||
bool isOrganizer = attendee?.Status?.Response == ResponseType.Organizer;
|
||||
// Check if this attendee is the organizer by comparing email addresses
|
||||
bool isOrganizer = !string.IsNullOrEmpty(organizerEmail) &&
|
||||
!string.IsNullOrEmpty(attendee?.EmailAddress?.Address) &&
|
||||
string.Equals(attendee.EmailAddress.Address, organizerEmail, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var eventAttendee = new CalendarEventAttendee()
|
||||
{
|
||||
@@ -281,6 +385,40 @@ public static class OutlookIntegratorExtensions
|
||||
|
||||
#region Mime to Outlook Message Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all attachments (inline and regular) from a MimeMessage
|
||||
/// and returns them as Graph SDK FileAttachment objects.
|
||||
/// </summary>
|
||||
public static List<FileAttachment> ExtractAttachments(this MimeMessage mime)
|
||||
{
|
||||
var attachments = new List<FileAttachment>();
|
||||
|
||||
foreach (var part in mime.BodyParts)
|
||||
{
|
||||
bool isInline = part.ContentDisposition?.Disposition == "inline";
|
||||
|
||||
if (!part.IsAttachment && !isInline)
|
||||
continue;
|
||||
|
||||
if (part is not MimePart mimePart || mimePart.Content == null)
|
||||
continue;
|
||||
|
||||
using var memory = new MemoryStream();
|
||||
mimePart.Content.DecodeTo(memory);
|
||||
|
||||
attachments.Add(new FileAttachment()
|
||||
{
|
||||
Name = part.ContentDisposition?.FileName ?? part.ContentType.Name,
|
||||
ContentBytes = memory.ToArray(),
|
||||
ContentType = part.ContentType.MimeType,
|
||||
ContentId = part.ContentId,
|
||||
IsInline = isInline
|
||||
});
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
private static IEnumerable<Recipient> GetRecipients(this InternetAddressList internetAddresses)
|
||||
{
|
||||
foreach (var address in internetAddresses)
|
||||
@@ -313,47 +451,47 @@ public static class OutlookIntegratorExtensions
|
||||
private static List<InternetMessageHeader> GetHeaderList(this MimeMessage mime)
|
||||
{
|
||||
// Graph API only allows max of 5 headers.
|
||||
// Here we'll try to ignore some headers that are not neccessary.
|
||||
// Outlook API will generate them automatically.
|
||||
|
||||
// Some headers also require to start with X- or x-.
|
||||
// Graph only allows setting custom internet headers (typically X-*).
|
||||
// Reply/threading headers like In-Reply-To and References are managed by
|
||||
// createReply/createReplyAll flows and must not be sent here.
|
||||
|
||||
const int headerLimit = 5;
|
||||
string[] headersToIgnore = ["Date", "To", "Cc", "Bcc", "MIME-Version", "From", "Subject", "Message-Id"];
|
||||
string[] headersToModify = ["In-Reply-To", "Reply-To", "References", "Thread-Topic"];
|
||||
|
||||
var headers = new List<InternetMessageHeader>();
|
||||
|
||||
int includedHeaderCount = 0;
|
||||
void AddHeader(string name, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) return;
|
||||
if (headers.Count >= headerLimit) return;
|
||||
if (headers.Any(h => string.Equals(h.Name, name, StringComparison.OrdinalIgnoreCase))) return;
|
||||
|
||||
// No header value should exceed 995 characters.
|
||||
var headerValue = value.Length >= 995 ? value.Substring(0, 995) : value;
|
||||
headers.Add(new InternetMessageHeader() { Name = name, Value = headerValue });
|
||||
}
|
||||
|
||||
// PRIORITY: Always include WinoLocalDraftHeader first if it exists.
|
||||
var winoDraftHeader = mime.Headers.FirstOrDefault(h => h.Field == Domain.Constants.WinoLocalDraftHeader);
|
||||
if (winoDraftHeader != null)
|
||||
AddHeader(winoDraftHeader.Field, winoDraftHeader.Value);
|
||||
|
||||
// Fill remaining slots with custom headers only (avoid Graph restrictions).
|
||||
foreach (var header in mime.Headers)
|
||||
{
|
||||
if (!headersToIgnore.Contains(header.Field))
|
||||
{
|
||||
var headerName = headersToModify.Contains(header.Field) ? $"X-{header.Field}" : header.Field;
|
||||
if (headers.Count >= headerLimit) break;
|
||||
if (header.Field == Domain.Constants.WinoLocalDraftHeader) continue;
|
||||
if (headersToIgnore.Contains(header.Field)) continue;
|
||||
|
||||
// No header value should exceed 995 characters.
|
||||
var headerValue = header.Value.Length >= 995 ? header.Value.Substring(0, 995) : header.Value;
|
||||
// Only include custom headers beyond the core threading ones.
|
||||
if (!header.Field.StartsWith("X-", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
headers.Add(new InternetMessageHeader() { Name = headerName, Value = headerValue });
|
||||
includedHeaderCount++;
|
||||
}
|
||||
|
||||
if (includedHeaderCount >= 5) break;
|
||||
AddHeader(header.Field, header.Value);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static string GetProperId(string id)
|
||||
{
|
||||
// Outlook requires some identifiers to start with "X-" or "x-".
|
||||
if (string.IsNullOrEmpty(id)) return string.Empty;
|
||||
|
||||
if (!id.StartsWith("x-") || !id.StartsWith("X-"))
|
||||
return $"X-{id}";
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Core.Helpers;
|
||||
|
||||
public sealed record PreparedCalendarEventCreateModel(
|
||||
CalendarItem CalendarItem,
|
||||
List<CalendarEventAttendee> Attendees,
|
||||
List<Reminder> Reminders);
|
||||
|
||||
public static class CalendarEventComposeMapper
|
||||
{
|
||||
public static PreparedCalendarEventCreateModel Prepare(CalendarEventComposeResult composeResult, AccountCalendar assignedCalendar, Guid? calendarItemId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composeResult);
|
||||
ArgumentNullException.ThrowIfNull(assignedCalendar);
|
||||
|
||||
var itemId = calendarItemId ?? Guid.NewGuid();
|
||||
var effectiveTimeZoneId = string.IsNullOrWhiteSpace(composeResult.TimeZoneId)
|
||||
? TimeZoneInfo.Local.Id
|
||||
: composeResult.TimeZoneId;
|
||||
var utcNow = DateTimeOffset.UtcNow;
|
||||
|
||||
var calendarItem = new CalendarItem
|
||||
{
|
||||
Id = itemId,
|
||||
CalendarId = assignedCalendar.Id,
|
||||
AssignedCalendar = assignedCalendar,
|
||||
Title = composeResult.Title?.Trim() ?? string.Empty,
|
||||
Description = composeResult.HtmlNotes ?? string.Empty,
|
||||
Location = composeResult.Location?.Trim() ?? string.Empty,
|
||||
StartDate = composeResult.StartDate,
|
||||
DurationInSeconds = Math.Max(0, (composeResult.EndDate - composeResult.StartDate).TotalSeconds),
|
||||
StartTimeZone = effectiveTimeZoneId,
|
||||
EndTimeZone = effectiveTimeZoneId,
|
||||
CreatedAt = utcNow,
|
||||
UpdatedAt = utcNow,
|
||||
Recurrence = composeResult.Recurrence ?? string.Empty,
|
||||
OrganizerDisplayName = assignedCalendar.MailAccount?.SenderName ?? string.Empty,
|
||||
OrganizerEmail = assignedCalendar.MailAccount?.Address ?? string.Empty,
|
||||
Status = CalendarItemStatus.Accepted,
|
||||
Visibility = CalendarItemVisibility.Public,
|
||||
ShowAs = composeResult.ShowAs,
|
||||
IsHidden = false,
|
||||
IsLocked = false
|
||||
};
|
||||
|
||||
var attendees = composeResult.Attendees?
|
||||
.Where(attendee => attendee != null)
|
||||
.Select(attendee => new CalendarEventAttendee
|
||||
{
|
||||
Id = attendee.Id == Guid.Empty ? Guid.NewGuid() : attendee.Id,
|
||||
CalendarItemId = itemId,
|
||||
Name = attendee.Name ?? string.Empty,
|
||||
Email = attendee.Email ?? string.Empty,
|
||||
Comment = attendee.Comment,
|
||||
AttendenceStatus = attendee.AttendenceStatus,
|
||||
IsOrganizer = attendee.IsOrganizer,
|
||||
IsOptionalAttendee = attendee.IsOptionalAttendee,
|
||||
ResolvedContact = attendee.ResolvedContact
|
||||
})
|
||||
.ToList() ?? [];
|
||||
|
||||
var reminders = composeResult.SelectedReminders?
|
||||
.Where(reminder => reminder != null)
|
||||
.Select(reminder => new Reminder
|
||||
{
|
||||
Id = reminder.Id == Guid.Empty ? Guid.NewGuid() : reminder.Id,
|
||||
CalendarItemId = itemId,
|
||||
DurationInSeconds = reminder.DurationInSeconds,
|
||||
ReminderType = reminder.ReminderType
|
||||
})
|
||||
.ToList() ?? [];
|
||||
|
||||
return new PreparedCalendarEventCreateModel(calendarItem, attendees, reminders);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.Graph.Models;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
|
||||
namespace Wino.Core.Helpers;
|
||||
|
||||
public static class CalendarRecurrenceMapper
|
||||
{
|
||||
public static PatternedRecurrence CreateOutlookRecurrence(CalendarItem calendarItem)
|
||||
{
|
||||
if (calendarItem == null || string.IsNullOrWhiteSpace(calendarItem.Recurrence))
|
||||
return null;
|
||||
|
||||
var ruleLine = calendarItem.Recurrence
|
||||
.Split(Domain.Constants.CalendarEventRecurrenceRuleSeperator, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Trim())
|
||||
.FirstOrDefault(line => line.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ruleLine))
|
||||
return null;
|
||||
|
||||
var components = ruleLine["RRULE:".Length..]
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(part => part.Split('=', 2, StringSplitOptions.TrimEntries))
|
||||
.Where(parts => parts.Length == 2)
|
||||
.ToDictionary(parts => parts[0].ToUpperInvariant(), parts => parts[1], StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!components.TryGetValue("FREQ", out var frequency))
|
||||
return null;
|
||||
|
||||
var pattern = new RecurrencePattern
|
||||
{
|
||||
Interval = ParseInt(components, "INTERVAL", 1),
|
||||
FirstDayOfWeek = DayOfWeekObject.Monday
|
||||
};
|
||||
|
||||
var byDays = ParseByDays(components);
|
||||
var startDate = calendarItem.StartDate;
|
||||
|
||||
switch (frequency.ToUpperInvariant())
|
||||
{
|
||||
case "DAILY":
|
||||
pattern.Type = RecurrencePatternType.Daily;
|
||||
break;
|
||||
case "WEEKLY":
|
||||
pattern.Type = RecurrencePatternType.Weekly;
|
||||
pattern.DaysOfWeek = byDays.Any()
|
||||
? byDays.Select(day => (DayOfWeekObject?)day).ToList()
|
||||
: [(DayOfWeekObject?)MapDay(startDate.DayOfWeek)];
|
||||
break;
|
||||
case "MONTHLY":
|
||||
if (byDays.Any())
|
||||
{
|
||||
pattern.Type = RecurrencePatternType.RelativeMonthly;
|
||||
pattern.DaysOfWeek = byDays.Select(day => (DayOfWeekObject?)day).ToList();
|
||||
pattern.Index = MapWeekIndex(startDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
pattern.Type = RecurrencePatternType.AbsoluteMonthly;
|
||||
pattern.DayOfMonth = ParseInt(components, "BYMONTHDAY", startDate.Day);
|
||||
}
|
||||
break;
|
||||
case "YEARLY":
|
||||
pattern.Month = ParseInt(components, "BYMONTH", startDate.Month);
|
||||
|
||||
if (byDays.Any())
|
||||
{
|
||||
pattern.Type = RecurrencePatternType.RelativeYearly;
|
||||
pattern.DaysOfWeek = byDays.Select(day => (DayOfWeekObject?)day).ToList();
|
||||
pattern.Index = MapWeekIndex(startDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
pattern.Type = RecurrencePatternType.AbsoluteYearly;
|
||||
pattern.DayOfMonth = ParseInt(components, "BYMONTHDAY", startDate.Day);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
var recurrenceRange = CreateRange(components, calendarItem);
|
||||
return new PatternedRecurrence
|
||||
{
|
||||
Pattern = pattern,
|
||||
Range = recurrenceRange
|
||||
};
|
||||
}
|
||||
|
||||
private static RecurrenceRange CreateRange(IReadOnlyDictionary<string, string> components, CalendarItem calendarItem)
|
||||
{
|
||||
var startDate = CreateDate(calendarItem.StartDate);
|
||||
|
||||
if (components.TryGetValue("UNTIL", out var untilValue) &&
|
||||
TryParseUntil(untilValue, out var untilDate))
|
||||
{
|
||||
return new RecurrenceRange
|
||||
{
|
||||
Type = RecurrenceRangeType.EndDate,
|
||||
StartDate = startDate,
|
||||
EndDate = CreateDate(untilDate),
|
||||
RecurrenceTimeZone = calendarItem.StartTimeZone
|
||||
};
|
||||
}
|
||||
|
||||
return new RecurrenceRange
|
||||
{
|
||||
Type = RecurrenceRangeType.NoEnd,
|
||||
StartDate = startDate,
|
||||
RecurrenceTimeZone = calendarItem.StartTimeZone
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseUntil(string untilValue, out DateTime untilDate)
|
||||
{
|
||||
untilDate = default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(untilValue))
|
||||
return false;
|
||||
|
||||
return DateTime.TryParseExact(
|
||||
untilValue,
|
||||
["yyyyMMdd", "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmmss'Z'"],
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out untilDate)
|
||||
|| DateTime.TryParse(untilValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out untilDate);
|
||||
}
|
||||
|
||||
private static List<DayOfWeekObject> ParseByDays(IReadOnlyDictionary<string, string> components)
|
||||
{
|
||||
if (!components.TryGetValue("BYDAY", out var byDayValue) || string.IsNullOrWhiteSpace(byDayValue))
|
||||
return [];
|
||||
|
||||
return byDayValue
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(MapDay)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int ParseInt(IReadOnlyDictionary<string, string> components, string key, int fallback)
|
||||
=> components.TryGetValue(key, out var value) && int.TryParse(value, out var parsedValue) ? parsedValue : fallback;
|
||||
|
||||
private static DayOfWeekObject MapDay(string dayToken)
|
||||
{
|
||||
return dayToken.ToUpperInvariant() switch
|
||||
{
|
||||
"MO" => DayOfWeekObject.Monday,
|
||||
"TU" => DayOfWeekObject.Tuesday,
|
||||
"WE" => DayOfWeekObject.Wednesday,
|
||||
"TH" => DayOfWeekObject.Thursday,
|
||||
"FR" => DayOfWeekObject.Friday,
|
||||
"SA" => DayOfWeekObject.Saturday,
|
||||
"SU" => DayOfWeekObject.Sunday,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(dayToken), dayToken, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static DayOfWeekObject MapDay(DayOfWeek dayOfWeek)
|
||||
{
|
||||
return dayOfWeek switch
|
||||
{
|
||||
DayOfWeek.Monday => DayOfWeekObject.Monday,
|
||||
DayOfWeek.Tuesday => DayOfWeekObject.Tuesday,
|
||||
DayOfWeek.Wednesday => DayOfWeekObject.Wednesday,
|
||||
DayOfWeek.Thursday => DayOfWeekObject.Thursday,
|
||||
DayOfWeek.Friday => DayOfWeekObject.Friday,
|
||||
DayOfWeek.Saturday => DayOfWeekObject.Saturday,
|
||||
DayOfWeek.Sunday => DayOfWeekObject.Sunday,
|
||||
_ => DayOfWeekObject.Monday
|
||||
};
|
||||
}
|
||||
|
||||
private static WeekIndex MapWeekIndex(DateTime date)
|
||||
{
|
||||
var occurrence = ((date.Day - 1) / 7) + 1;
|
||||
|
||||
return occurrence switch
|
||||
{
|
||||
1 => WeekIndex.First,
|
||||
2 => WeekIndex.Second,
|
||||
3 => WeekIndex.Third,
|
||||
4 => WeekIndex.Fourth,
|
||||
_ => WeekIndex.Last
|
||||
};
|
||||
}
|
||||
|
||||
private static Date CreateDate(DateTime dateTime) => new(dateTime.Year, dateTime.Month, dateTime.Day);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Calendar;
|
||||
using Wino.Core.Requests.Folder;
|
||||
using Wino.Core.Requests.Mail;
|
||||
|
||||
namespace Wino.Core.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Converts queued synchronization requests into user-facing action descriptions.
|
||||
/// </summary>
|
||||
public static class SynchronizationActionHelper
|
||||
{
|
||||
public static List<SynchronizationActionItem> CreateActionItems(
|
||||
IEnumerable<IRequestBase> requests, Guid accountId, string accountName)
|
||||
{
|
||||
var items = new List<SynchronizationActionItem>();
|
||||
|
||||
// Group mail action requests by operation
|
||||
var mailRequests = requests.OfType<IMailActionRequest>();
|
||||
var mailGroups = mailRequests.GroupBy(r => GetMailActionKey(r));
|
||||
|
||||
foreach (var group in mailGroups)
|
||||
{
|
||||
var description = GetMailActionDescription(group.Key, group.ToList());
|
||||
|
||||
if (description != null)
|
||||
{
|
||||
items.Add(new SynchronizationActionItem
|
||||
{
|
||||
AccountId = accountId,
|
||||
AccountName = accountName,
|
||||
Description = description
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle folder action requests individually
|
||||
var folderRequests = requests.OfType<IFolderActionRequest>();
|
||||
foreach (var folderRequest in folderRequests)
|
||||
{
|
||||
var description = GetFolderActionDescription(folderRequest);
|
||||
|
||||
if (description != null)
|
||||
{
|
||||
items.Add(new SynchronizationActionItem
|
||||
{
|
||||
AccountId = accountId,
|
||||
AccountName = accountName,
|
||||
Description = description
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var calendarRequests = requests.OfType<ICalendarActionRequest>();
|
||||
foreach (var calendarRequest in calendarRequests)
|
||||
{
|
||||
var description = GetCalendarActionDescription(calendarRequest);
|
||||
|
||||
if (description != null)
|
||||
{
|
||||
items.Add(new SynchronizationActionItem
|
||||
{
|
||||
AccountId = accountId,
|
||||
AccountName = accountName,
|
||||
Description = description
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a key that differentiates MarkRead vs MarkUnread, Flag vs Unflag, Archive vs Unarchive.
|
||||
/// </summary>
|
||||
private static string GetMailActionKey(IMailActionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
MarkReadRequest r => r.IsRead ? "MarkRead" : "MarkUnread",
|
||||
ChangeFlagRequest r => r.IsFlagged ? "SetFlag" : "ClearFlag",
|
||||
ArchiveRequest r => r.IsArchiving ? "Archive" : "Unarchive",
|
||||
_ => request.Operation.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetMailActionDescription(string actionKey, List<IMailActionRequest> requests)
|
||||
{
|
||||
int count = requests.Count;
|
||||
|
||||
return actionKey switch
|
||||
{
|
||||
"MarkRead" => string.Format(Translator.SyncAction_MarkingAsRead, count),
|
||||
"MarkUnread" => string.Format(Translator.SyncAction_MarkingAsUnread, count),
|
||||
"Delete" => string.Format(Translator.SyncAction_Deleting, count),
|
||||
"Move" => string.Format(Translator.SyncAction_Moving, count),
|
||||
"Archive" => string.Format(Translator.SyncAction_Archiving, count),
|
||||
"Unarchive" => string.Format(Translator.SyncAction_Unarchiving, count),
|
||||
"SetFlag" => string.Format(Translator.SyncAction_SettingFlag, count),
|
||||
"ClearFlag" => string.Format(Translator.SyncAction_ClearingFlag, count),
|
||||
"CreateDraft" => Translator.SyncAction_CreatingDraft,
|
||||
"Send" => Translator.SyncAction_SendingMail,
|
||||
"MoveToFocused" => string.Format(Translator.SyncAction_MovingToFocused, count),
|
||||
"AlwaysMoveTo" => string.Format(Translator.SyncAction_Moving, count),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetFolderActionDescription(IFolderActionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
RenameFolderRequest => Translator.SyncAction_RenamingFolder,
|
||||
EmptyFolderRequest => Translator.SyncAction_EmptyingFolder,
|
||||
MarkFolderAsReadRequest => Translator.SyncAction_MarkingFolderAsRead,
|
||||
DeleteFolderRequest => Translator.FolderOperation_Delete,
|
||||
CreateSubFolderRequest => Translator.FolderOperation_CreateSubFolder,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetCalendarActionDescription(ICalendarActionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
CreateCalendarEventRequest => Translator.SyncAction_CreatingEvent,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace Wino.Core.Http;
|
||||
|
||||
/// <summary>
|
||||
/// DelegatingHandler that automatically handles Microsoft Graph API 429 rate limiting responses.
|
||||
/// Integrates directly with the Graph SDK HTTP pipeline to provide transparent retry functionality.
|
||||
///
|
||||
/// Features:
|
||||
/// - Intercepts 429 (Too Many Requests) HTTP responses before they become ServiceExceptions
|
||||
/// - Respects Retry-After header from responses (both seconds and HTTP date formats)
|
||||
/// - Maximum 3 retry attempts to prevent infinite loops
|
||||
/// - Caps retry delays to 5 minutes maximum
|
||||
/// - Uses 60-second default delay if no Retry-After header is provided
|
||||
/// - Comprehensive logging for debugging and monitoring
|
||||
/// - Thread-safe and cancellation token aware
|
||||
/// - Integrates seamlessly with existing Graph SDK error handling
|
||||
///
|
||||
/// Usage:
|
||||
/// Add to GraphServiceClient handlers in OutlookSynchronizer constructor:
|
||||
///
|
||||
/// var handlers = GraphClientFactory.CreateDefaultHandlers();
|
||||
/// handlers.Add(new MicrosoftImmutableIdHandler());
|
||||
/// handlers.Add(new GraphRateLimitHandler());
|
||||
/// var httpClient = GraphClientFactory.Create(handlers);
|
||||
/// </summary>
|
||||
public class GraphRateLimitHandler : DelegatingHandler
|
||||
{
|
||||
private static readonly ILogger _logger = Log.ForContext<GraphRateLimitHandler>();
|
||||
private const int MaxRetryAttempts = 3;
|
||||
private const int MaxDelaySeconds = 300; // 5 minutes cap
|
||||
private const int DefaultDelaySeconds = 60; // Default delay when no Retry-After header
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = 0;
|
||||
|
||||
while (attempt <= MaxRetryAttempts)
|
||||
{
|
||||
HttpResponseMessage response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Error sending request to {Uri} on attempt {Attempt}", request.RequestUri, attempt + 1);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Check if we got a 429 Too Many Requests response
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
if (attempt == MaxRetryAttempts)
|
||||
{
|
||||
_logger.Warning("Max retry attempts ({MaxAttempts}) reached for rate limited request to {Uri}",
|
||||
MaxRetryAttempts, request.RequestUri);
|
||||
return response; // Return the 429 response after max attempts
|
||||
}
|
||||
|
||||
// Get the Retry-After header value
|
||||
var retryAfterSeconds = GetRetryAfterSeconds(response);
|
||||
|
||||
if (retryAfterSeconds > 0)
|
||||
{
|
||||
// Cap the delay to a reasonable maximum
|
||||
var cappedDelay = Math.Min(retryAfterSeconds, MaxDelaySeconds);
|
||||
|
||||
_logger.Information("Rate limited (429) - waiting {RetrySeconds} seconds before retry attempt {Attempt}/{MaxAttempts} for {Uri}",
|
||||
cappedDelay, attempt + 1, MaxRetryAttempts, request.RequestUri);
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(cappedDelay), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("Rate limited (429) but no valid Retry-After header found for {Uri} - using default {DefaultDelay} second delay",
|
||||
request.RequestUri, DefaultDelaySeconds);
|
||||
|
||||
// Use a default delay if no Retry-After header is provided
|
||||
await Task.Delay(TimeSpan.FromSeconds(DefaultDelaySeconds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
attempt++;
|
||||
response.Dispose(); // Dispose the 429 response before retry
|
||||
continue;
|
||||
}
|
||||
|
||||
// Success or other error - return the response
|
||||
return response;
|
||||
}
|
||||
|
||||
// This should never be reached, but just in case
|
||||
throw new InvalidOperationException("Rate limiting retry logic error");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the retry delay from the Retry-After header.
|
||||
/// Supports both seconds (integer) and HTTP date formats.
|
||||
/// </summary>
|
||||
/// <param name="response">The HTTP response containing Retry-After header</param>
|
||||
/// <returns>Number of seconds to wait, or 0 if header is missing or invalid</returns>
|
||||
private int GetRetryAfterSeconds(HttpResponseMessage response)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if Retry-After header exists
|
||||
if (response.Headers.RetryAfter == null)
|
||||
{
|
||||
_logger.Debug("No Retry-After header found in response");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handle retry-after-seconds (integer)
|
||||
if (response.Headers.RetryAfter.Delta.HasValue)
|
||||
{
|
||||
var seconds = (int)response.Headers.RetryAfter.Delta.Value.TotalSeconds;
|
||||
_logger.Debug("Found Retry-After delta: {Seconds} seconds", seconds);
|
||||
return seconds;
|
||||
}
|
||||
|
||||
// Handle retry-after-date (HTTP date)
|
||||
if (response.Headers.RetryAfter.Date.HasValue)
|
||||
{
|
||||
var retryAfterTime = response.Headers.RetryAfter.Date.Value;
|
||||
var delaySeconds = (int)(retryAfterTime - DateTimeOffset.UtcNow).TotalSeconds;
|
||||
_logger.Debug("Found Retry-After date: {Date}, calculated delay: {Seconds} seconds", retryAfterTime, delaySeconds);
|
||||
|
||||
// Ensure we don't have a negative delay
|
||||
return Math.Max(0, delaySeconds);
|
||||
}
|
||||
|
||||
_logger.Debug("Retry-After header present but no valid value found");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Error parsing Retry-After header");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Wino.Core.Integration;
|
||||
|
||||
internal sealed class ImapServerQuirkProfile
|
||||
{
|
||||
public static readonly ImapServerQuirkProfile Default = new();
|
||||
|
||||
public bool DisableQResync { get; init; }
|
||||
public bool DisableCondstore { get; init; }
|
||||
public bool UseConservativeConnections { get; init; }
|
||||
}
|
||||
|
||||
internal static class ImapServerQuirks
|
||||
{
|
||||
private static readonly Dictionary<string, ImapServerQuirkProfile> Quirks = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Some strict providers are more stable with conservative behavior.
|
||||
["qq.com"] = new ImapServerQuirkProfile { DisableQResync = true, UseConservativeConnections = true },
|
||||
["163.com"] = new ImapServerQuirkProfile { DisableQResync = true, UseConservativeConnections = true },
|
||||
["126.com"] = new ImapServerQuirkProfile { DisableQResync = true, UseConservativeConnections = true },
|
||||
["yeah.net"] = new ImapServerQuirkProfile { DisableQResync = true, UseConservativeConnections = true }
|
||||
};
|
||||
|
||||
public static ImapServerQuirkProfile Resolve(string host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
return ImapServerQuirkProfile.Default;
|
||||
|
||||
foreach (var (key, profile) in Quirks)
|
||||
{
|
||||
if (host.Contains(key, StringComparison.OrdinalIgnoreCase))
|
||||
return profile;
|
||||
}
|
||||
|
||||
return ImapServerQuirkProfile.Default;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Requests.Folder;
|
||||
using Wino.Core.Requests.Mail;
|
||||
|
||||
namespace Wino.Core.Integration.Json;
|
||||
|
||||
public class ServerRequestTypeInfoResolver : DefaultJsonTypeInfoResolver
|
||||
{
|
||||
public ServerRequestTypeInfoResolver()
|
||||
{
|
||||
Modifiers.Add(new System.Action<JsonTypeInfo>(t =>
|
||||
{
|
||||
if (t.Type == typeof(IRequestBase))
|
||||
{
|
||||
t.PolymorphismOptions = new()
|
||||
{
|
||||
DerivedTypes =
|
||||
{
|
||||
new JsonDerivedType(typeof(AlwaysMoveToRequest), nameof(AlwaysMoveToRequest)),
|
||||
new JsonDerivedType(typeof(ArchiveRequest), nameof(ArchiveRequest)),
|
||||
new JsonDerivedType(typeof(ChangeFlagRequest), nameof(ChangeFlagRequest)),
|
||||
new JsonDerivedType(typeof(CreateDraftRequest), nameof(CreateDraftRequest)),
|
||||
new JsonDerivedType(typeof(DeleteRequest), nameof(DeleteRequest)),
|
||||
new JsonDerivedType(typeof(EmptyFolderRequest), nameof(EmptyFolderRequest)),
|
||||
new JsonDerivedType(typeof(MarkFolderAsReadRequest), nameof(MarkFolderAsReadRequest)),
|
||||
new JsonDerivedType(typeof(MarkReadRequest), nameof(MarkReadRequest)),
|
||||
new JsonDerivedType(typeof(MoveRequest), nameof(MoveRequest)),
|
||||
new JsonDerivedType(typeof(MoveToFocusedRequest), nameof(MoveToFocusedRequest)),
|
||||
new JsonDerivedType(typeof(RenameFolderRequest), nameof(RenameFolderRequest)),
|
||||
new JsonDerivedType(typeof(SendDraftRequest), nameof(SendDraftRequest)),
|
||||
}
|
||||
};
|
||||
}
|
||||
else if (t.Type == typeof(IMailItem))
|
||||
{
|
||||
t.PolymorphismOptions = new JsonPolymorphismOptions()
|
||||
{
|
||||
DerivedTypes =
|
||||
{
|
||||
new JsonDerivedType(typeof(MailCopy), nameof(MailCopy)),
|
||||
}
|
||||
};
|
||||
}
|
||||
else if (t.Type == typeof(IMailItemFolder))
|
||||
{
|
||||
t.PolymorphismOptions = new JsonPolymorphismOptions()
|
||||
{
|
||||
DerivedTypes =
|
||||
{
|
||||
new JsonDerivedType(typeof(MailItemFolder), nameof(MailItemFolder)),
|
||||
}
|
||||
};
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Services;
|
||||
|
||||
@@ -44,6 +45,8 @@ public interface IDefaultChangeProcessor
|
||||
Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId);
|
||||
|
||||
Task DeleteCalendarItemAsync(Guid calendarItemId);
|
||||
Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId);
|
||||
Task<CalendarItem> GetCalendarItemAsync(Guid calendarId, string remoteEventId);
|
||||
|
||||
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||
@@ -53,6 +56,8 @@ public interface IDefaultChangeProcessor
|
||||
Task<List<MailCopy>> GetMailCopiesAsync(IEnumerable<string> mailCopyIds);
|
||||
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
|
||||
Task DeleteUserMailCacheAsync(Guid accountId);
|
||||
Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping);
|
||||
Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the mail exists in the folder.
|
||||
@@ -106,6 +111,19 @@ public interface IImapChangeProcessor : IDefaultChangeProcessor
|
||||
/// </summary>
|
||||
/// <param name="folderId">Folder id to retrieve uIds for.</param>
|
||||
Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent mail IDs for a folder (for notification purposes).
|
||||
/// </summary>
|
||||
/// <param name="folderId">Folder ID.</param>
|
||||
/// <param name="count">Number of recent mails to return.</param>
|
||||
Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count);
|
||||
|
||||
Task ManageCalendarEventAsync(CalDavCalendarEvent calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount);
|
||||
Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent);
|
||||
Task<string> GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId);
|
||||
Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId);
|
||||
Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId);
|
||||
}
|
||||
|
||||
public class DefaultChangeProcessor(IDatabaseService databaseService,
|
||||
@@ -185,9 +203,15 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
|
||||
public Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId)
|
||||
=> CalendarService.GetAccountCalendarsAsync(accountId);
|
||||
|
||||
public Task DeleteCalendarItemAsync(Guid calendarItemId)
|
||||
public virtual Task DeleteCalendarItemAsync(Guid calendarItemId)
|
||||
=> CalendarService.DeleteCalendarItemAsync(calendarItemId);
|
||||
|
||||
public virtual Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
|
||||
=> CalendarService.DeleteCalendarItemAsync(calendarRemoteEventId, calendarId);
|
||||
|
||||
public Task<CalendarItem> GetCalendarItemAsync(Guid calendarId, string remoteEventId)
|
||||
=> CalendarService.GetCalendarItemAsync(calendarId, remoteEventId);
|
||||
|
||||
public Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar)
|
||||
=> CalendarService.DeleteAccountCalendarAsync(accountCalendar);
|
||||
|
||||
@@ -206,6 +230,43 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
|
||||
await AccountService.DeleteAccountMailCacheAsync(accountId, AccountCacheResetReason.ExpiredCache).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping)
|
||||
{
|
||||
if (mapping == null || mapping.AccountId == Guid.Empty || string.IsNullOrWhiteSpace(mapping.MailCopyId))
|
||||
return;
|
||||
|
||||
var existing = await Connection.Table<MailInvitationCalendarMapping>()
|
||||
.FirstOrDefaultAsync(x => x.AccountId == mapping.AccountId && x.MailCopyId == mapping.MailCopyId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
if (mapping.Id == Guid.Empty)
|
||||
mapping.Id = Guid.NewGuid();
|
||||
|
||||
mapping.UpdatedAtUtc = DateTime.UtcNow;
|
||||
await Connection.InsertAsync(mapping, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
existing.InvitationUid = mapping.InvitationUid;
|
||||
existing.CalendarId = mapping.CalendarId;
|
||||
existing.CalendarItemId = mapping.CalendarItemId;
|
||||
existing.CalendarRemoteEventId = mapping.CalendarRemoteEventId;
|
||||
existing.UpdatedAtUtc = DateTime.UtcNow;
|
||||
|
||||
await Connection.UpdateAsync(existing, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId)
|
||||
{
|
||||
if (accountId == Guid.Empty || string.IsNullOrWhiteSpace(mailCopyId))
|
||||
return Task.FromResult<MailInvitationCalendarMapping>(null);
|
||||
|
||||
return Connection.Table<MailInvitationCalendarMapping>()
|
||||
.FirstOrDefaultAsync(x => x.AccountId == accountId && x.MailCopyId == mailCopyId);
|
||||
}
|
||||
|
||||
public Task<bool> IsMailExistsInFolderAsync(string messageId, Guid folderId)
|
||||
=> MailService.IsMailExistsAsync(messageId, folderId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Apis.Calendar.v3.Data;
|
||||
using Serilog;
|
||||
@@ -65,12 +66,14 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
// We don't have this event yet. Create a new one.
|
||||
var eventStartDateTimeOffset = GoogleIntegratorExtensions.GetEventDateTimeOffset(calendarEvent.Start);
|
||||
var eventEndDateTimeOffset = GoogleIntegratorExtensions.GetEventDateTimeOffset(calendarEvent.End);
|
||||
var eventStartLocalDateTime = GoogleIntegratorExtensions.GetEventLocalDateTime(calendarEvent.Start);
|
||||
var eventEndLocalDateTime = GoogleIntegratorExtensions.GetEventLocalDateTime(calendarEvent.End);
|
||||
|
||||
double totalDurationInSeconds = 0;
|
||||
|
||||
if (eventStartDateTimeOffset != null && eventEndDateTimeOffset != null)
|
||||
if (eventStartLocalDateTime != null && eventEndLocalDateTime != null)
|
||||
{
|
||||
totalDurationInSeconds = (eventEndDateTimeOffset.Value - eventStartDateTimeOffset.Value).TotalSeconds;
|
||||
totalDurationInSeconds = (eventEndLocalDateTime.Value - eventStartLocalDateTime.Value).TotalSeconds;
|
||||
}
|
||||
|
||||
CalendarItem calendarItem = null;
|
||||
@@ -96,18 +99,21 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Description = calendarEvent.Description ?? parentRecurringEvent.Description,
|
||||
Id = Guid.NewGuid(),
|
||||
StartDate = eventStartDateTimeOffset.Value.DateTime,
|
||||
StartDateOffset = eventStartDateTimeOffset.Value.Offset,
|
||||
EndDateOffset = eventEndDateTimeOffset?.Offset ?? parentRecurringEvent.EndDateOffset,
|
||||
StartDate = eventStartLocalDateTime.Value,
|
||||
DurationInSeconds = totalDurationInSeconds,
|
||||
Location = string.IsNullOrEmpty(calendarEvent.Location) ? parentRecurringEvent.Location : calendarEvent.Location,
|
||||
|
||||
// Store timezone information
|
||||
StartTimeZone = GoogleIntegratorExtensions.GetEventTimeZone(calendarEvent.Start) ?? parentRecurringEvent.StartTimeZone,
|
||||
EndTimeZone = GoogleIntegratorExtensions.GetEventTimeZone(calendarEvent.End) ?? parentRecurringEvent.EndTimeZone,
|
||||
|
||||
// Leave it empty if it's not populated.
|
||||
Recurrence = GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent) == null ? string.Empty : GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent),
|
||||
Status = GetStatus(calendarEvent.Status),
|
||||
Title = string.IsNullOrEmpty(calendarEvent.Summary) ? parentRecurringEvent.Title : calendarEvent.Summary,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
Visibility = string.IsNullOrEmpty(calendarEvent.Visibility) ? parentRecurringEvent.Visibility : GetVisibility(calendarEvent.Visibility),
|
||||
ShowAs = string.IsNullOrEmpty(calendarEvent.Transparency) ? parentRecurringEvent.ShowAs : GetShowAs(calendarEvent.Transparency),
|
||||
HtmlLink = string.IsNullOrEmpty(calendarEvent.HtmlLink) ? parentRecurringEvent.HtmlLink : calendarEvent.HtmlLink,
|
||||
RemoteEventId = calendarEvent.Id,
|
||||
IsLocked = calendarEvent.Locked.GetValueOrDefault(),
|
||||
@@ -132,16 +138,20 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Description = calendarEvent.Description,
|
||||
Id = Guid.NewGuid(),
|
||||
StartDate = eventStartDateTimeOffset.Value.DateTime,
|
||||
StartDateOffset = eventStartDateTimeOffset.Value.Offset,
|
||||
EndDateOffset = eventEndDateTimeOffset.Value.Offset,
|
||||
StartDate = eventStartLocalDateTime.Value,
|
||||
DurationInSeconds = totalDurationInSeconds,
|
||||
Location = calendarEvent.Location,
|
||||
|
||||
// Store timezone information from Google Calendar event
|
||||
StartTimeZone = GoogleIntegratorExtensions.GetEventTimeZone(calendarEvent.Start),
|
||||
EndTimeZone = GoogleIntegratorExtensions.GetEventTimeZone(calendarEvent.End),
|
||||
|
||||
Recurrence = GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent),
|
||||
Status = GetStatus(calendarEvent.Status),
|
||||
Title = calendarEvent.Summary,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
Visibility = GetVisibility(calendarEvent.Visibility),
|
||||
ShowAs = GetShowAs(calendarEvent.Transparency),
|
||||
HtmlLink = calendarEvent.HtmlLink,
|
||||
RemoteEventId = calendarEvent.Id,
|
||||
IsLocked = calendarEvent.Locked.GetValueOrDefault(),
|
||||
@@ -153,6 +163,9 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
// Hide canceled events.
|
||||
calendarItem.IsHidden = calendarItem.Status == CalendarItemStatus.Cancelled;
|
||||
|
||||
// Set assigned calendar for navigation properties to work.
|
||||
calendarItem.AssignedCalendar = assignedCalendar;
|
||||
|
||||
// Manage the recurring event id.
|
||||
if (parentRecurringEvent != null)
|
||||
{
|
||||
@@ -218,7 +231,64 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare reminders list from Gmail event
|
||||
List<Reminder> reminders = null;
|
||||
if (calendarEvent.Reminders?.Overrides != null && calendarEvent.Reminders.Overrides.Count > 0)
|
||||
{
|
||||
reminders = new List<Reminder>();
|
||||
foreach (var reminderOverride in calendarEvent.Reminders.Overrides)
|
||||
{
|
||||
if (reminderOverride.Minutes.HasValue)
|
||||
{
|
||||
var durationInSeconds = reminderOverride.Minutes.Value * 60; // Convert minutes to seconds
|
||||
var reminderType = reminderOverride.Method switch
|
||||
{
|
||||
"email" => CalendarItemReminderType.Email,
|
||||
_ => CalendarItemReminderType.Popup
|
||||
};
|
||||
|
||||
reminders.Add(new Reminder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = calendarItem.Id,
|
||||
DurationInSeconds = durationInSeconds,
|
||||
ReminderType = reminderType
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare attachments metadata from Gmail event
|
||||
List<CalendarAttachment> attachments = null;
|
||||
if (calendarEvent.Attachments != null && calendarEvent.Attachments.Count > 0)
|
||||
{
|
||||
attachments = calendarEvent.Attachments
|
||||
.Where(a => a != null && !string.IsNullOrEmpty(a.Title))
|
||||
.Select(a => new CalendarAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = calendarItem.Id,
|
||||
RemoteAttachmentId = a.FileId ?? a.FileUrl, // Gmail uses FileId or FileUrl
|
||||
FileName = a.Title,
|
||||
Size = 0, // Gmail API doesn't provide size in Event.Attachment
|
||||
ContentType = a.MimeType ?? "application/octet-stream",
|
||||
IsDownloaded = false,
|
||||
LocalFilePath = null,
|
||||
LastModified = DateTimeOffset.UtcNow
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
await CalendarService.CreateNewCalendarItemAsync(calendarItem, attendees);
|
||||
|
||||
// Save reminders separately
|
||||
await CalendarService.SaveRemindersAsync(calendarItem.Id, reminders).ConfigureAwait(false);
|
||||
|
||||
// Save attachments metadata separately
|
||||
if (attachments != null && attachments.Count > 0)
|
||||
{
|
||||
await CalendarService.InsertOrReplaceAttachmentsAsync(attachments).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -250,10 +320,67 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
|
||||
// Update the event properties.
|
||||
}
|
||||
|
||||
// Prepare reminders list from Gmail event for update
|
||||
List<Reminder> reminders = null;
|
||||
if (calendarEvent.Reminders?.Overrides != null && calendarEvent.Reminders.Overrides.Count > 0)
|
||||
{
|
||||
reminders = new List<Reminder>();
|
||||
foreach (var reminderOverride in calendarEvent.Reminders.Overrides)
|
||||
{
|
||||
if (reminderOverride.Minutes.HasValue)
|
||||
{
|
||||
var durationInSeconds = reminderOverride.Minutes.Value * 60; // Convert minutes to seconds
|
||||
var reminderType = reminderOverride.Method switch
|
||||
{
|
||||
"email" => CalendarItemReminderType.Email,
|
||||
_ => CalendarItemReminderType.Popup
|
||||
};
|
||||
|
||||
reminders.Add(new Reminder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = existingCalendarItem.Id,
|
||||
DurationInSeconds = durationInSeconds,
|
||||
ReminderType = reminderType
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save reminders
|
||||
await CalendarService.SaveRemindersAsync(existingCalendarItem.Id, reminders).ConfigureAwait(false);
|
||||
|
||||
// Prepare attachments metadata from Gmail event for update
|
||||
List<CalendarAttachment> attachments = null;
|
||||
if (calendarEvent.Attachments != null && calendarEvent.Attachments.Count > 0)
|
||||
{
|
||||
attachments = calendarEvent.Attachments
|
||||
.Where(a => a != null && !string.IsNullOrEmpty(a.Title))
|
||||
.Select(a => new CalendarAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = existingCalendarItem.Id,
|
||||
RemoteAttachmentId = a.FileId ?? a.FileUrl,
|
||||
FileName = a.Title,
|
||||
Size = 0,
|
||||
ContentType = a.MimeType ?? "application/octet-stream",
|
||||
IsDownloaded = false,
|
||||
LocalFilePath = null,
|
||||
LastModified = DateTimeOffset.UtcNow
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Save attachments metadata
|
||||
if (attachments != null && attachments.Count > 0)
|
||||
{
|
||||
await CalendarService.InsertOrReplaceAttachmentsAsync(attachments).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert the event.
|
||||
await Connection.InsertOrReplaceAsync(existingCalendarItem);
|
||||
await Connection.InsertOrReplaceAsync(existingCalendarItem, typeof(CalendarItem));
|
||||
}
|
||||
|
||||
private string GetOrganizerName(Event calendarEvent, MailAccount account)
|
||||
@@ -284,10 +411,10 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
"confirmed" => CalendarItemStatus.Confirmed,
|
||||
"confirmed" => CalendarItemStatus.Accepted,
|
||||
"tentative" => CalendarItemStatus.Tentative,
|
||||
"cancelled" => CalendarItemStatus.Cancelled,
|
||||
_ => CalendarItemStatus.Confirmed
|
||||
_ => CalendarItemStatus.Accepted
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,6 +436,20 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
};
|
||||
}
|
||||
|
||||
private CalendarItemShowAs GetShowAs(string transparency)
|
||||
{
|
||||
/// Google Calendar uses "transparent" for free time (event doesn't block time)
|
||||
/// and "opaque" for busy time (event blocks time on the calendar).
|
||||
/// If not specified, defaults to opaque (busy).
|
||||
|
||||
return transparency switch
|
||||
{
|
||||
"transparent" => CalendarItemShowAs.Free,
|
||||
"opaque" => CalendarItemShowAs.Busy,
|
||||
_ => CalendarItemShowAs.Busy
|
||||
};
|
||||
}
|
||||
|
||||
public Task<bool> HasAccountAnyDraftAsync(Guid accountId)
|
||||
=> MailService.HasAccountAnyDraftAsync(accountId);
|
||||
|
||||
|
||||
@@ -1,21 +1,203 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Services;
|
||||
|
||||
namespace Wino.Core.Integration.Processors;
|
||||
|
||||
public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor
|
||||
{
|
||||
private readonly ICalendarIcsFileService _calendarIcsFileService;
|
||||
|
||||
public ImapChangeProcessor(IDatabaseService databaseService,
|
||||
IFolderService folderService,
|
||||
IMailService mailService,
|
||||
IAccountService accountService,
|
||||
ICalendarService calendarService,
|
||||
IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService)
|
||||
IMimeFileService mimeFileService,
|
||||
ICalendarIcsFileService calendarIcsFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService)
|
||||
{
|
||||
_calendarIcsFileService = calendarIcsFileService;
|
||||
}
|
||||
|
||||
public Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId) => FolderService.GetKnownUidsForFolderAsync(folderId);
|
||||
|
||||
public Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
|
||||
=> MailService.GetRecentMailIdsForFolderAsync(folderId, count);
|
||||
|
||||
public async Task ManageCalendarEventAsync(CalDavCalendarEvent calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
|
||||
{
|
||||
if (calendarEvent == null || assignedCalendar == null)
|
||||
return;
|
||||
|
||||
var existingItem = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.RemoteEventId).ConfigureAwait(false);
|
||||
var isNewItem = existingItem == null;
|
||||
var savingItemId = existingItem?.Id ?? Guid.NewGuid();
|
||||
var savingItem = existingItem ?? new CalendarItem { Id = savingItemId };
|
||||
|
||||
var startTimeZone = NormalizeTimeZoneId(calendarEvent.StartTimeZone, calendarEvent.Start);
|
||||
var endTimeZone = NormalizeTimeZoneId(calendarEvent.EndTimeZone, calendarEvent.End);
|
||||
if (string.IsNullOrWhiteSpace(endTimeZone))
|
||||
endTimeZone = startTimeZone;
|
||||
|
||||
var start = ConvertToEventWallClock(calendarEvent.Start, startTimeZone);
|
||||
var end = ConvertToEventWallClock(calendarEvent.End, endTimeZone);
|
||||
|
||||
var durationInSeconds = (calendarEvent.End - calendarEvent.Start).TotalSeconds;
|
||||
if (durationInSeconds <= 0)
|
||||
{
|
||||
if (end <= start)
|
||||
end = start.AddHours(1);
|
||||
|
||||
durationInSeconds = (end - start).TotalSeconds;
|
||||
}
|
||||
|
||||
savingItem.RemoteEventId = calendarEvent.RemoteEventId;
|
||||
savingItem.CalendarId = assignedCalendar.Id;
|
||||
savingItem.StartDate = start;
|
||||
savingItem.DurationInSeconds = durationInSeconds;
|
||||
savingItem.StartTimeZone = startTimeZone;
|
||||
savingItem.EndTimeZone = endTimeZone;
|
||||
savingItem.Title = calendarEvent.Title;
|
||||
savingItem.Description = calendarEvent.Description;
|
||||
savingItem.Location = calendarEvent.Location;
|
||||
savingItem.Recurrence = calendarEvent.Recurrence;
|
||||
savingItem.Status = calendarEvent.Status;
|
||||
savingItem.Visibility = calendarEvent.Visibility;
|
||||
savingItem.ShowAs = calendarEvent.ShowAs;
|
||||
savingItem.IsHidden = calendarEvent.IsHidden;
|
||||
savingItem.HtmlLink = string.Empty;
|
||||
savingItem.IsLocked = false;
|
||||
savingItem.OrganizerDisplayName = !string.IsNullOrWhiteSpace(calendarEvent.OrganizerDisplayName)
|
||||
? calendarEvent.OrganizerDisplayName
|
||||
: organizerAccount?.SenderName ?? string.Empty;
|
||||
savingItem.OrganizerEmail = !string.IsNullOrWhiteSpace(calendarEvent.OrganizerEmail)
|
||||
? calendarEvent.OrganizerEmail
|
||||
: organizerAccount?.Address ?? string.Empty;
|
||||
savingItem.AssignedCalendar = assignedCalendar;
|
||||
|
||||
if (savingItem.CreatedAt == default)
|
||||
savingItem.CreatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
savingItem.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(calendarEvent.SeriesMasterRemoteEventId))
|
||||
{
|
||||
var parentEvent = await CalendarService
|
||||
.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.SeriesMasterRemoteEventId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (parentEvent != null)
|
||||
{
|
||||
savingItem.RecurringCalendarItemId = parentEvent.Id;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
savingItem.RecurringCalendarItemId = null;
|
||||
}
|
||||
|
||||
var attendees = calendarEvent.Attendees?
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Email))
|
||||
.Select(a => new CalendarEventAttendee
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = savingItemId,
|
||||
Name = a.Name,
|
||||
Email = a.Email,
|
||||
AttendenceStatus = a.AttendenceStatus,
|
||||
IsOrganizer = a.IsOrganizer,
|
||||
IsOptionalAttendee = a.IsOptionalAttendee
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var reminders = calendarEvent.Reminders?
|
||||
.Where(r => r.DurationInSeconds > 0)
|
||||
.Select(r => new Reminder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = savingItemId,
|
||||
DurationInSeconds = r.DurationInSeconds,
|
||||
ReminderType = r.ReminderType
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (isNewItem)
|
||||
{
|
||||
await CalendarService.CreateNewCalendarItemAsync(savingItem, attendees).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await CalendarService.UpdateCalendarItemAsync(savingItem, attendees).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await CalendarService.SaveRemindersAsync(savingItemId, reminders).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent)
|
||||
=> _calendarIcsFileService.SaveCalendarItemIcsAsync(accountId, calendarId, calendarItemId, remoteEventId, remoteResourceHref, eTag, icsContent);
|
||||
|
||||
public Task<string> GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId)
|
||||
=> _calendarIcsFileService.GetCalendarItemIcsETagAsync(accountId, calendarId, calendarItemId);
|
||||
|
||||
public Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId)
|
||||
=> _calendarIcsFileService.DeleteCalendarItemIcsAsync(accountId, calendarItemId);
|
||||
|
||||
public Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId)
|
||||
=> _calendarIcsFileService.DeleteCalendarIcsForCalendarAsync(accountId, calendarId);
|
||||
|
||||
public override async Task DeleteCalendarItemAsync(Guid calendarItemId)
|
||||
{
|
||||
var item = await CalendarService.GetCalendarItemAsync(calendarItemId).ConfigureAwait(false);
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
await _calendarIcsFileService.DeleteCalendarItemIcsAsync(item.AssignedCalendar?.AccountId ?? Guid.Empty, calendarItemId).ConfigureAwait(false);
|
||||
await base.DeleteCalendarItemAsync(calendarItemId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override async Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
|
||||
{
|
||||
var item = await CalendarService.GetCalendarItemAsync(calendarId, calendarRemoteEventId).ConfigureAwait(false);
|
||||
if (item == null)
|
||||
return;
|
||||
|
||||
await DeleteCalendarItemAsync(item.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string NormalizeTimeZoneId(string timeZoneId, DateTimeOffset value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(timeZoneId))
|
||||
return timeZoneId;
|
||||
|
||||
if (value != default && value.Offset == TimeSpan.Zero)
|
||||
return TimeZoneInfo.Utc.Id;
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static DateTime ConvertToEventWallClock(DateTimeOffset value, string eventTimeZoneId)
|
||||
{
|
||||
if (value == default)
|
||||
return default;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(eventTimeZoneId))
|
||||
return DateTime.SpecifyKind(value.DateTime, DateTimeKind.Unspecified);
|
||||
|
||||
try
|
||||
{
|
||||
var eventTimeZone = TimeZoneInfo.FindSystemTimeZoneById(eventTimeZoneId);
|
||||
var inEventTimeZone = TimeZoneInfo.ConvertTime(value, eventTimeZone);
|
||||
return DateTime.SpecifyKind(inEventTimeZone.DateTime, DateTimeKind.Unspecified);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DateTime.SpecifyKind(value.DateTime, DateTimeKind.Unspecified);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Graph.Models;
|
||||
@@ -7,8 +8,10 @@ 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.Extensions;
|
||||
using Wino.Core.Extensions;
|
||||
using Wino.Services;
|
||||
using Reminder = Wino.Core.Domain.Entities.Calendar.Reminder;
|
||||
|
||||
namespace Wino.Core.Integration.Processors;
|
||||
|
||||
@@ -40,15 +43,13 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
|
||||
public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
|
||||
{
|
||||
// We parse the occurrences based on the parent event.
|
||||
// There is literally no point to store them because
|
||||
// type=Exception events are the exceptional childs of recurrency parent event.
|
||||
|
||||
if (calendarEvent.Type == EventType.Occurrence) return;
|
||||
// All event types are now handled: SingleInstance, SeriesMaster, Occurrence, and Exception.
|
||||
// Occurrences from CalendarView are individual instances that are saved separately.
|
||||
|
||||
var savingItem = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.Id);
|
||||
|
||||
Guid savingItemId = Guid.Empty;
|
||||
bool isNewItem = savingItem == null;
|
||||
|
||||
if (savingItem != null)
|
||||
savingItemId = savingItem.Id;
|
||||
@@ -58,25 +59,33 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
savingItem = new CalendarItem() { Id = savingItemId };
|
||||
}
|
||||
|
||||
DateTimeOffset eventStartDateTimeOffset = OutlookIntegratorExtensions.GetDateTimeOffsetFromDateTimeTimeZone(calendarEvent.Start);
|
||||
DateTimeOffset eventEndDateTimeOffset = OutlookIntegratorExtensions.GetDateTimeOffsetFromDateTimeTimeZone(calendarEvent.End);
|
||||
var eventStartLocalDateTime = OutlookIntegratorExtensions.GetLocalDateTimeFromDateTimeTimeZone(calendarEvent.Start);
|
||||
var eventEndLocalDateTime = OutlookIntegratorExtensions.GetLocalDateTimeFromDateTimeTimeZone(calendarEvent.End);
|
||||
|
||||
var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds;
|
||||
var durationInSeconds = (eventEndLocalDateTime - eventStartLocalDateTime).TotalSeconds;
|
||||
|
||||
savingItem.RemoteEventId = calendarEvent.Id;
|
||||
savingItem.StartDate = eventStartDateTimeOffset.DateTime;
|
||||
savingItem.StartDateOffset = eventStartDateTimeOffset.Offset;
|
||||
savingItem.EndDateOffset = eventEndDateTimeOffset.Offset;
|
||||
// Store the wall-clock values exactly as Outlook returned them for the event timezone.
|
||||
// Timed events are converted for display later, while all-day events stay as floating dates.
|
||||
savingItem.RemoteEventId = calendarEvent.Id.WithClientTrackingId(calendarEvent.TransactionId.GetClientTrackingId());
|
||||
savingItem.StartDate = eventStartLocalDateTime;
|
||||
savingItem.DurationInSeconds = durationInSeconds;
|
||||
|
||||
// Store the timezone information from the event
|
||||
// This preserves the original timezone from Outlook, allowing proper reconstruction later
|
||||
// If no timezone is provided, null will indicate UTC
|
||||
savingItem.StartTimeZone = calendarEvent.Start?.TimeZone;
|
||||
savingItem.EndTimeZone = calendarEvent.End?.TimeZone;
|
||||
|
||||
savingItem.Title = calendarEvent.Subject;
|
||||
savingItem.Description = calendarEvent.Body?.Content;
|
||||
savingItem.Location = calendarEvent.Location?.DisplayName;
|
||||
|
||||
if (calendarEvent.Type == EventType.Exception && !string.IsNullOrEmpty(calendarEvent.SeriesMasterId))
|
||||
// Handle recurring event relationships for both Exception and Occurrence types
|
||||
if ((calendarEvent.Type == EventType.Exception || calendarEvent.Type == EventType.Occurrence)
|
||||
&& !string.IsNullOrEmpty(calendarEvent.SeriesMasterId))
|
||||
{
|
||||
// This is a recurring event exception.
|
||||
// We need to find the parent event and set it as recurring event id.
|
||||
// This is a recurring event instance (either an exception or a regular occurrence).
|
||||
// Link it to the parent series master.
|
||||
|
||||
var parentEvent = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.SeriesMasterId);
|
||||
|
||||
@@ -86,12 +95,14 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning($"Parent recurring event is missing for event. Skipping creation of {calendarEvent.Id}");
|
||||
return;
|
||||
// Parent not found yet - this can happen if occurrences sync before the series master.
|
||||
// We still save the event but without the parent link for now.
|
||||
Log.Warning($"Parent recurring event (SeriesMasterId: {calendarEvent.SeriesMasterId}) not found for event {calendarEvent.Id}. Event will be saved without parent link.");
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the recurrence pattern to string for parent recurring events.
|
||||
// Note: We store this for reference but don't use it to calculate occurrences.
|
||||
if (calendarEvent.Type == EventType.SeriesMaster && calendarEvent.Recurrence != null)
|
||||
{
|
||||
savingItem.Recurrence = OutlookIntegratorExtensions.ToRfc5545RecurrenceString(calendarEvent.Recurrence);
|
||||
@@ -103,6 +114,52 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
savingItem.OrganizerDisplayName = calendarEvent.Organizer?.EmailAddress?.Name;
|
||||
savingItem.IsHidden = false;
|
||||
|
||||
// Set timestamps
|
||||
if (calendarEvent.CreatedDateTime.HasValue)
|
||||
savingItem.CreatedAt = calendarEvent.CreatedDateTime.Value;
|
||||
|
||||
if (calendarEvent.LastModifiedDateTime.HasValue)
|
||||
savingItem.UpdatedAt = calendarEvent.LastModifiedDateTime.Value;
|
||||
|
||||
// Set visibility
|
||||
if (calendarEvent.Sensitivity != null)
|
||||
{
|
||||
savingItem.Visibility = calendarEvent.Sensitivity.Value switch
|
||||
{
|
||||
Sensitivity.Normal => CalendarItemVisibility.Public,
|
||||
Sensitivity.Personal => CalendarItemVisibility.Private,
|
||||
Sensitivity.Private => CalendarItemVisibility.Private,
|
||||
Sensitivity.Confidential => CalendarItemVisibility.Confidential,
|
||||
_ => CalendarItemVisibility.Public
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
savingItem.Visibility = CalendarItemVisibility.Public;
|
||||
}
|
||||
|
||||
// Set ShowAs status
|
||||
if (calendarEvent.ShowAs != null)
|
||||
{
|
||||
savingItem.ShowAs = calendarEvent.ShowAs.Value switch
|
||||
{
|
||||
Microsoft.Graph.Models.FreeBusyStatus.Free => CalendarItemShowAs.Free,
|
||||
Microsoft.Graph.Models.FreeBusyStatus.Tentative => CalendarItemShowAs.Tentative,
|
||||
Microsoft.Graph.Models.FreeBusyStatus.Busy => CalendarItemShowAs.Busy,
|
||||
Microsoft.Graph.Models.FreeBusyStatus.Oof => CalendarItemShowAs.OutOfOffice,
|
||||
Microsoft.Graph.Models.FreeBusyStatus.WorkingElsewhere => CalendarItemShowAs.WorkingElsewhere,
|
||||
_ => CalendarItemShowAs.Busy
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
savingItem.ShowAs = CalendarItemShowAs.Busy;
|
||||
}
|
||||
|
||||
// Set IsLocked based on whether the user is the organizer
|
||||
// Read-only events are those where the current user is not the organizer
|
||||
savingItem.IsLocked = calendarEvent.IsOrganizer.HasValue && !calendarEvent.IsOrganizer.Value;
|
||||
|
||||
if (calendarEvent.ResponseStatus?.Response != null)
|
||||
{
|
||||
switch (calendarEvent.ResponseStatus.Response.Value)
|
||||
@@ -116,7 +173,7 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
break;
|
||||
case ResponseType.Accepted:
|
||||
case ResponseType.Organizer:
|
||||
savingItem.Status = CalendarItemStatus.Confirmed;
|
||||
savingItem.Status = CalendarItemStatus.Accepted;
|
||||
break;
|
||||
case ResponseType.Declined:
|
||||
savingItem.Status = CalendarItemStatus.Cancelled;
|
||||
@@ -128,18 +185,80 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
}
|
||||
else
|
||||
{
|
||||
savingItem.Status = CalendarItemStatus.Confirmed;
|
||||
savingItem.Status = CalendarItemStatus.Accepted;
|
||||
}
|
||||
|
||||
// Upsert the event.
|
||||
await Connection.InsertOrReplaceAsync(savingItem);
|
||||
|
||||
// Manage attendees.
|
||||
// Prepare attendees list
|
||||
List<CalendarEventAttendee> attendees = null;
|
||||
if (calendarEvent.Attendees != null)
|
||||
{
|
||||
// Clear all attendees for this event.
|
||||
var attendees = calendarEvent.Attendees.Select(a => a.CreateAttendee(savingItemId)).ToList();
|
||||
await CalendarService.ManageEventAttendeesAsync(savingItemId, attendees).ConfigureAwait(false);
|
||||
// Pass the organizer's email address to properly identify the organizer in the attendees list
|
||||
string organizerEmail = calendarEvent.Organizer?.EmailAddress?.Address;
|
||||
attendees = calendarEvent.Attendees.Select(a => a.CreateAttendee(savingItemId, organizerEmail)).ToList();
|
||||
}
|
||||
|
||||
// Prepare reminders list from Outlook event
|
||||
List<Reminder> reminders = null;
|
||||
if (calendarEvent.IsReminderOn.GetValueOrDefault() && calendarEvent.ReminderMinutesBeforeStart.HasValue)
|
||||
{
|
||||
var reminderMinutes = calendarEvent.ReminderMinutesBeforeStart.Value;
|
||||
var reminderDurationInSeconds = reminderMinutes * 60; // Convert minutes to seconds
|
||||
|
||||
reminders = new List<Reminder>
|
||||
{
|
||||
new Reminder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = savingItemId,
|
||||
DurationInSeconds = reminderDurationInSeconds,
|
||||
ReminderType = CalendarItemReminderType.Popup
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare attachments metadata from Outlook event
|
||||
List<CalendarAttachment> attachments = null;
|
||||
if (calendarEvent.HasAttachments.GetValueOrDefault() && calendarEvent.Attachments != null)
|
||||
{
|
||||
attachments = calendarEvent.Attachments
|
||||
.Where(a => a != null && !string.IsNullOrEmpty(a.Name))
|
||||
.Select(a => new CalendarAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CalendarItemId = savingItemId,
|
||||
RemoteAttachmentId = a.Id,
|
||||
FileName = a.Name,
|
||||
Size = a.Size ?? 0,
|
||||
ContentType = a.ContentType ?? "application/octet-stream",
|
||||
IsDownloaded = false,
|
||||
LocalFilePath = null,
|
||||
LastModified = calendarEvent.LastModifiedDateTime ?? DateTimeOffset.UtcNow
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Set assigned calendar for navigation properties to work.
|
||||
savingItem.AssignedCalendar = assignedCalendar;
|
||||
|
||||
// Use CalendarService to create or update the event
|
||||
if (isNewItem)
|
||||
{
|
||||
// New item - use CreateNewCalendarItemAsync
|
||||
await CalendarService.CreateNewCalendarItemAsync(savingItem, attendees).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Existing item - use UpdateCalendarItemAsync
|
||||
await CalendarService.UpdateCalendarItemAsync(savingItem, attendees).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Save reminders separately
|
||||
await CalendarService.SaveRemindersAsync(savingItemId, reminders).ConfigureAwait(false);
|
||||
|
||||
// Save attachments metadata separately
|
||||
if (attachments != null && attachments.Count > 0)
|
||||
{
|
||||
await CalendarService.InsertOrReplaceAttachmentsAsync(attachments).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
@@ -9,7 +9,7 @@ namespace Wino.Core.Integration;
|
||||
/// <summary>
|
||||
/// Extended class for ImapClient that is used in Wino.
|
||||
/// </summary>
|
||||
internal class WinoImapClient : ImapClient
|
||||
public class WinoImapClient : ImapClient
|
||||
{
|
||||
private int _busyCount;
|
||||
|
||||
@@ -24,11 +24,6 @@ internal class WinoImapClient : ImapClient
|
||||
HookEvents();
|
||||
}
|
||||
|
||||
public WinoImapClient(IProtocolLogger protocolLogger) : base(protocolLogger)
|
||||
{
|
||||
HookEvents();
|
||||
}
|
||||
|
||||
private void HookEvents()
|
||||
{
|
||||
Disconnected += ClientDisconnected;
|
||||
|
||||
@@ -1,49 +1,100 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Wino.Core.Domain.Misc;
|
||||
|
||||
namespace Wino.Core.Misc;
|
||||
|
||||
public static class ColorHelpers
|
||||
{
|
||||
public static string GenerateFlatColorHex()
|
||||
public static IReadOnlyList<string> GetFlatColorPalette() => CalendarColorPalette.GetColors();
|
||||
|
||||
public static string GenerateFlatColorHex() => GetDistinctFlatColorHex(Array.Empty<string>());
|
||||
|
||||
public static string GetDistinctFlatColorHex(IEnumerable<string> usedColors, string preferredColor = null)
|
||||
{
|
||||
Random random = new();
|
||||
int hue = random.Next(0, 360); // Full hue range
|
||||
int saturation = 70 + random.Next(30); // High saturation (70-100%)
|
||||
int lightness = 50 + random.Next(20); // Bright colors (50-70%)
|
||||
var palette = CalendarColorPalette.GetColors();
|
||||
var normalizedUsedColors = usedColors?
|
||||
.Select(NormalizeHexColor)
|
||||
.Where(color => !string.IsNullOrWhiteSpace(color))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var color = FromHsl(hue, saturation, lightness);
|
||||
if (TryNormalizeHexColor(preferredColor, out var normalizedPreferred) &&
|
||||
palette.Contains(normalizedPreferred, StringComparer.OrdinalIgnoreCase) &&
|
||||
!normalizedUsedColors.Contains(normalizedPreferred))
|
||||
{
|
||||
return normalizedPreferred;
|
||||
}
|
||||
|
||||
return ToHexString(color);
|
||||
var distinctColor = CalendarColorPalette.GetDistinctColor(usedColors);
|
||||
if (palette.Contains(distinctColor))
|
||||
{
|
||||
return distinctColor;
|
||||
}
|
||||
|
||||
var candidate = AdjustColor(palette[0], 1);
|
||||
|
||||
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})";
|
||||
|
||||
private static Color FromHsl(int h, int s, int l)
|
||||
private static string AdjustColor(string hexColor, int cycle)
|
||||
{
|
||||
double hue = h / 360.0;
|
||||
double saturation = s / 100.0;
|
||||
double lightness = l / 100.0;
|
||||
var color = ColorTranslator.FromHtml(hexColor);
|
||||
var factor = Math.Max(0.55, 1.0 - (cycle * 0.08));
|
||||
|
||||
// Conversion from HSL to RGB
|
||||
var chroma = (1 - Math.Abs(2 * lightness - 1)) * saturation;
|
||||
var x = chroma * (1 - Math.Abs((hue * 6) % 2 - 1));
|
||||
var m = lightness - chroma / 2;
|
||||
var adjusted = Color.FromArgb(
|
||||
(int)Math.Clamp(color.R * factor, 0, 255),
|
||||
(int)Math.Clamp(color.G * factor, 0, 255),
|
||||
(int)Math.Clamp(color.B * factor, 0, 255));
|
||||
|
||||
double r = 0, g = 0, b = 0;
|
||||
|
||||
if (hue < 1.0 / 6.0) { r = chroma; g = x; b = 0; }
|
||||
else if (hue < 2.0 / 6.0) { r = x; g = chroma; b = 0; }
|
||||
else if (hue < 3.0 / 6.0) { r = 0; g = chroma; b = x; }
|
||||
else if (hue < 4.0 / 6.0) { r = 0; g = x; b = chroma; }
|
||||
else if (hue < 5.0 / 6.0) { r = x; g = 0; b = chroma; }
|
||||
else { r = chroma; g = 0; b = x; }
|
||||
|
||||
return Color.FromArgb(
|
||||
(int)((r + m) * 255),
|
||||
(int)((g + m) * 255),
|
||||
(int)((b + m) * 255));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Wino.Core.Misc;
|
||||
|
||||
public class OutlookFileAttachment
|
||||
{
|
||||
[JsonPropertyName("@odata.type")]
|
||||
public string OdataType { get; } = "#microsoft.graph.fileAttachment";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string FileName { get; set; }
|
||||
|
||||
[JsonPropertyName("contentBytes")]
|
||||
public string Base64EncodedContentBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("contentType")]
|
||||
public string ContentType { get; set; }
|
||||
|
||||
[JsonPropertyName("contentId")]
|
||||
public string ContentId { get; set; }
|
||||
|
||||
[JsonPropertyName("isInline")]
|
||||
public bool IsInline { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Wino.Core.Tests")]
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
@@ -21,12 +21,10 @@ public record HttpRequestBundle<TRequest>(TRequest NativeRequest, IUIChangeReque
|
||||
/// <param name="BatchRequest">Batch request that is generated by base synchronizer.</param>
|
||||
public record HttpRequestBundle<TRequest, TResponse>(TRequest NativeRequest, IRequestBase Request) : HttpRequestBundle<TRequest>(NativeRequest, Request)
|
||||
{
|
||||
[RequiresDynamicCode("AOT")]
|
||||
[RequiresUnreferencedCode("AOT")]
|
||||
public async Task<TResponse> DeserializeBundleAsync(HttpResponseMessage httpResponse, CancellationToken cancellationToken = default)
|
||||
public async Task<TResponse> DeserializeBundleAsync(HttpResponseMessage httpResponse, JsonTypeInfo<TResponse> typeInfo, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var content = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
return JsonSerializer.Deserialize<TResponse>(content) ?? throw new InvalidOperationException("Invalid Http Response Deserialization");
|
||||
return JsonSerializer.Deserialize(content, typeInfo) ?? throw new InvalidOperationException("Invalid Http Response Deserialization");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,20 @@ public class ImapRequest
|
||||
{
|
||||
public Func<IImapClient, IRequestBase, Task> IntegratorTask { get; }
|
||||
public IRequestBase Request { get; }
|
||||
public bool RequiresConnectedClient { get; }
|
||||
|
||||
public ImapRequest(Func<IImapClient, IRequestBase, Task> integratorTask, IRequestBase request)
|
||||
public ImapRequest(Func<IImapClient, IRequestBase, Task> integratorTask, IRequestBase request, bool requiresConnectedClient = true)
|
||||
{
|
||||
IntegratorTask = integratorTask;
|
||||
Request = request;
|
||||
RequiresConnectedClient = requiresConnectedClient;
|
||||
}
|
||||
}
|
||||
|
||||
public class ImapRequest<TRequestBaseType> : ImapRequest where TRequestBaseType : IRequestBase
|
||||
{
|
||||
public ImapRequest(Func<IImapClient, TRequestBaseType, Task> integratorTask, TRequestBaseType request)
|
||||
: base((client, request) => integratorTask(client, (TRequestBaseType)request), request)
|
||||
public ImapRequest(Func<IImapClient, TRequestBaseType, Task> integratorTask, TRequestBaseType request, bool requiresConnectedClient = true)
|
||||
: base((client, request) => integratorTask(client, (TRequestBaseType)request), request, requiresConnectedClient)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
namespace Wino.Core.Requests.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// Request to accept a calendar event invitation on the server.
|
||||
/// The calendar item status should be updated locally before queuing this request.
|
||||
/// </summary>
|
||||
public record AcceptEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item)
|
||||
{
|
||||
private readonly CalendarItemStatus _previousStatus = Item.Status;
|
||||
|
||||
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.AcceptEvent;
|
||||
|
||||
/// <summary>
|
||||
/// After successful acceptance, we need to resync to get updated status.
|
||||
/// </summary>
|
||||
public override int ResynchronizationDelay => 2000;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// Update the item status locally
|
||||
Item.Status = CalendarItemStatus.Accepted;
|
||||
|
||||
// Notify UI that the event status was updated
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientUpdated));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
// If acceptance fails, revert to the previous status
|
||||
Item.Status = _previousStatus;
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Core.Helpers;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
namespace Wino.Core.Requests.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new calendar event on the server.
|
||||
/// Non-recurring events create an optimistic in-memory item for immediate UI feedback.
|
||||
/// Recurring events skip optimistic rendering and rely on provider synchronization to materialize instances.
|
||||
/// </summary>
|
||||
public record CreateCalendarEventRequest : CalendarRequestBase
|
||||
{
|
||||
public CalendarEventComposeResult ComposeResult { get; }
|
||||
public AccountCalendar AssignedCalendar { get; }
|
||||
public PreparedCalendarEventCreateModel PreparedEvent { get; }
|
||||
public CalendarItem PreparedItem => PreparedEvent.CalendarItem;
|
||||
public bool IsRecurring => !string.IsNullOrWhiteSpace(ComposeResult?.Recurrence);
|
||||
|
||||
public CreateCalendarEventRequest(CalendarEventComposeResult composeResult, AccountCalendar assignedCalendar)
|
||||
: this(composeResult, assignedCalendar, CalendarEventComposeMapper.Prepare(composeResult, assignedCalendar))
|
||||
{
|
||||
}
|
||||
|
||||
private CreateCalendarEventRequest(
|
||||
CalendarEventComposeResult composeResult,
|
||||
AccountCalendar assignedCalendar,
|
||||
PreparedCalendarEventCreateModel preparedEvent)
|
||||
: base(ShouldCreateOptimisticItem(composeResult) ? preparedEvent.CalendarItem : null)
|
||||
{
|
||||
ComposeResult = composeResult ?? throw new ArgumentNullException(nameof(composeResult));
|
||||
AssignedCalendar = assignedCalendar ?? throw new ArgumentNullException(nameof(assignedCalendar));
|
||||
PreparedEvent = preparedEvent ?? throw new ArgumentNullException(nameof(preparedEvent));
|
||||
}
|
||||
|
||||
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.CreateEvent;
|
||||
|
||||
public override int ResynchronizationDelay => 5000;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
if (Item == null)
|
||||
return;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
if (Item == null)
|
||||
return;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item));
|
||||
}
|
||||
|
||||
private static bool ShouldCreateOptimisticItem(CalendarEventComposeResult composeResult)
|
||||
=> string.IsNullOrWhiteSpace(composeResult?.Recurrence);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
namespace Wino.Core.Requests.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// Request to decline a calendar event invitation on the server.
|
||||
/// The calendar item status should be updated locally before queuing this request.
|
||||
/// </summary>
|
||||
public record DeclineEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item)
|
||||
{
|
||||
private readonly CalendarItemStatus _previousStatus = Item.Status;
|
||||
|
||||
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.DeclineEvent;
|
||||
|
||||
/// <summary>
|
||||
/// After successful decline, we need to resync to get updated status.
|
||||
/// </summary>
|
||||
public override int ResynchronizationDelay => 2000;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// Update the item status locally
|
||||
Item.Status = CalendarItemStatus.Cancelled;
|
||||
|
||||
// Notify UI that the event status was updated
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientUpdated));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
// If decline fails, revert to the previous status
|
||||
Item.Status = _previousStatus;
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
namespace Wino.Core.Requests.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// Request to delete a calendar event on the server.
|
||||
/// </summary>
|
||||
public record DeleteCalendarEventRequest(CalendarItem Item) : CalendarRequestBase(Item)
|
||||
{
|
||||
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.DeleteEvent;
|
||||
|
||||
/// <summary>
|
||||
/// After successful deletion, resync to confirm the event was removed.
|
||||
/// </summary>
|
||||
public override int ResynchronizationDelay => 2000;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// Notify UI that the event was deleted
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
// If deletion fails, we should notify the UI to add it back
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
namespace Wino.Core.Requests.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// Outlook-specific request to decline a calendar event invitation.
|
||||
/// In Outlook, declined events are removed from the calendar by the API after synchronization,
|
||||
/// so this request sends a delete notification to remove the event from the UI.
|
||||
/// </summary>
|
||||
public record OutlookDeclineEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item)
|
||||
{
|
||||
private readonly CalendarItemStatus _previousStatus = Item.Status;
|
||||
|
||||
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.DeclineEvent;
|
||||
|
||||
/// <summary>
|
||||
/// After successful decline, we need to resync to confirm the event is removed.
|
||||
/// </summary>
|
||||
public override int ResynchronizationDelay => 2000;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// In Outlook, declined events are deleted from the calendar after sync
|
||||
// Send deleted message to remove from UI immediately
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
// If decline fails, restore the previous status and re-add the event
|
||||
Item.Status = _previousStatus;
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
namespace Wino.Core.Requests.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// Request to tentatively accept a calendar event invitation on the server.
|
||||
/// The calendar item status should be updated locally before queuing this request.
|
||||
/// </summary>
|
||||
public record TentativeEventRequest(CalendarItem Item, string ResponseMessage = null) : CalendarRequestBase(Item)
|
||||
{
|
||||
private readonly CalendarItemStatus _previousStatus = Item.Status;
|
||||
|
||||
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.TentativeEvent;
|
||||
|
||||
/// <summary>
|
||||
/// After successful tentative acceptance, we need to resync to get updated status.
|
||||
/// </summary>
|
||||
public override int ResynchronizationDelay => 2000;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// Update the item status locally
|
||||
Item.Status = CalendarItemStatus.Tentative;
|
||||
|
||||
// Notify UI that the event status was updated
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientUpdated));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
// If tentative acceptance fails, revert to the previous status
|
||||
Item.Status = _previousStatus;
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Messaging.Client.Calendar;
|
||||
|
||||
namespace Wino.Core.Requests.Calendar;
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an existing calendar event on the server.
|
||||
/// The calendar item should be already updated in the local database before queuing this request.
|
||||
/// </summary>
|
||||
public record UpdateCalendarEventRequest(CalendarItem Item, List<CalendarEventAttendee> Attendees) : CalendarRequestBase(Item)
|
||||
{
|
||||
/// <summary>
|
||||
/// Original attendees before the update, used for reverting changes if the update fails.
|
||||
/// </summary>
|
||||
public List<CalendarEventAttendee> OriginalAttendees { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original calendar item state before the update, used for reverting changes if the update fails.
|
||||
/// </summary>
|
||||
public CalendarItem OriginalItem { get; init; }
|
||||
|
||||
public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.UpdateEvent;
|
||||
|
||||
/// <summary>
|
||||
/// After successful update, we need to resync to ensure changes are properly reflected.
|
||||
/// </summary>
|
||||
public override int ResynchronizationDelay => 2000;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// Notify UI that the event was updated locally
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientUpdated));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
// If update fails, restore the original state
|
||||
if (OriginalItem != null && OriginalAttendees != null)
|
||||
{
|
||||
// Send the original item back to restore UI state
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(OriginalItem, CalendarItemUpdateSource.ClientReverted));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: just notify with current item to trigger refresh
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests.Folder;
|
||||
|
||||
public record CreateSubFolderRequest(MailItemFolder Folder, string NewFolderName) : FolderRequestBase(Folder, FolderSynchronizerOperation.CreateSubFolder)
|
||||
{
|
||||
public override void ApplyUIChanges() { }
|
||||
public override void RevertUIChanges() { }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Messaging.UI;
|
||||
|
||||
namespace Wino.Core.Requests.Folder;
|
||||
|
||||
public record DeleteFolderRequest(MailItemFolder Folder) : FolderRequestBase(Folder, FolderSynchronizerOperation.DeleteFolder)
|
||||
{
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new FolderDeleted(Folder));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges() { }
|
||||
}
|
||||
@@ -17,10 +17,14 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List<MailCopy> Mail
|
||||
|
||||
foreach (var item in MailsToMarkRead)
|
||||
{
|
||||
// Skip if already read
|
||||
if (item.IsRead) continue;
|
||||
|
||||
item.IsRead = true;
|
||||
}
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new BulkMailUpdatedMessage(MailsToMarkRead));
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead));
|
||||
}
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
@@ -29,10 +33,14 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List<MailCopy> Mail
|
||||
|
||||
foreach (var item in MailsToMarkRead)
|
||||
{
|
||||
// Skip if already unread (wasn't changed by ApplyUIChanges)
|
||||
if (!item.IsRead) continue;
|
||||
|
||||
item.IsRead = false;
|
||||
}
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new BulkMailUpdatedMessage(MailsToMarkRead));
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead));
|
||||
}
|
||||
}
|
||||
|
||||
public List<Guid> SynchronizationFolderIds => [Folder.Id];
|
||||
|
||||
@@ -12,24 +12,38 @@ namespace Wino.Core.Requests.Mail;
|
||||
public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : MailRequestBase(Item),
|
||||
ICustomFolderSynchronizationRequest
|
||||
{
|
||||
private readonly bool _originalIsFlagged = Item.IsFlagged;
|
||||
|
||||
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
|
||||
|
||||
public bool ExcludeMustHaveFolders => true;
|
||||
|
||||
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.ChangeFlag;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this request represents an actual state change.
|
||||
/// If the mail is already in the desired flagged state, no change is needed.
|
||||
/// </summary>
|
||||
public bool IsNoOp { get; } = Item.IsFlagged == IsFlagged;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// Skip UI update if the mail is already in the desired state
|
||||
if (IsNoOp) return;
|
||||
|
||||
Item.IsFlagged = IsFlagged;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsFlagged));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Item.IsFlagged = !IsFlagged;
|
||||
// Skip UI revert if this was a no-op request
|
||||
if (IsNoOp) return;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
|
||||
Item.IsFlagged = _originalIsFlagged;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsFlagged));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Messaging.UI;
|
||||
|
||||
namespace Wino.Core.Requests.Mail;
|
||||
|
||||
@@ -24,6 +22,7 @@ public record CreateDraftRequest(DraftPreparationRequest DraftPreperationRequest
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item));
|
||||
// Keep local draft intact when create-draft synchronization fails.
|
||||
// This allows users to retry sending the local draft to the server.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,24 +11,38 @@ namespace Wino.Core.Requests.Mail;
|
||||
|
||||
public record MarkReadRequest(MailCopy Item, bool IsRead) : MailRequestBase(Item), ICustomFolderSynchronizationRequest
|
||||
{
|
||||
private readonly bool _originalIsRead = Item.IsRead;
|
||||
|
||||
public List<Guid> SynchronizationFolderIds => [Item.FolderId];
|
||||
|
||||
public override MailSynchronizerOperation Operation => MailSynchronizerOperation.MarkRead;
|
||||
|
||||
public bool ExcludeMustHaveFolders => true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this request represents an actual state change.
|
||||
/// If the mail is already in the desired read state, no change is needed.
|
||||
/// </summary>
|
||||
public bool IsNoOp { get; } = Item.IsRead == IsRead;
|
||||
|
||||
public override void ApplyUIChanges()
|
||||
{
|
||||
// Skip UI update if the mail is already in the desired state
|
||||
if (IsNoOp) return;
|
||||
|
||||
Item.IsRead = IsRead;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead));
|
||||
}
|
||||
|
||||
public override void RevertUIChanges()
|
||||
{
|
||||
Item.IsRead = !IsRead;
|
||||
// Skip UI revert if this was a no-op request
|
||||
if (IsNoOp) return;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item));
|
||||
Item.IsRead = _originalIsRead;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models;
|
||||
@@ -10,47 +15,682 @@ using Wino.Core.Domain.Models.AutoDiscovery;
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// We have 2 methods to do auto discovery.
|
||||
/// 1. Use https://emailsettings.firetrust.com/settings?q={address} API
|
||||
/// 2. TODO: Thunderbird auto discovery file.
|
||||
/// Mail and CalDAV endpoint discovery with Thunderbird-style methods and fallbacks.
|
||||
/// </summary>
|
||||
public class AutoDiscoveryService : IAutoDiscoveryService
|
||||
{
|
||||
private const string FiretrustURL = " https://emailsettings.firetrust.com/settings?q=";
|
||||
private const string ThunderbirdIspdbUrl = "https://autoconfig.thunderbird.net/v1.1/";
|
||||
private const string FiretrustUrl = "https://emailsettings.firetrust.com/settings?q=";
|
||||
private const string GoogleDnsResolveUrl = "https://dns.google/resolve";
|
||||
|
||||
// TODO: Try Thunderbird Auto Discovery as second approach.
|
||||
private static readonly ILogger Logger = Log.ForContext<AutoDiscoveryService>();
|
||||
private static readonly StringComparer IgnoreCase = StringComparer.OrdinalIgnoreCase;
|
||||
private static readonly HttpMethod OptionsMethod = new("OPTIONS");
|
||||
|
||||
public Task<AutoDiscoverySettings> GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings)
|
||||
=> GetSettingsFromFiretrustAsync(autoDiscoveryMinimalSettings.Email);
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly Dictionary<string, Uri> _calDavUriCache = new(IgnoreCase);
|
||||
private readonly object _calDavCacheLock = new();
|
||||
|
||||
private static async Task<AutoDiscoverySettings> GetSettingsFromFiretrustAsync(string mailAddress)
|
||||
public AutoDiscoveryService(HttpClient httpClient = null)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var response = await client.GetAsync($"{FiretrustURL}{mailAddress}");
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await DeserializeFiretrustResponse(response);
|
||||
else
|
||||
_httpClient = httpClient ?? new HttpClient
|
||||
{
|
||||
Log.Warning($"Firetrust AutoDiscovery failed. ({response.StatusCode})");
|
||||
|
||||
return null;
|
||||
}
|
||||
Timeout = TimeSpan.FromSeconds(15)
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<AutoDiscoverySettings> DeserializeFiretrustResponse(HttpResponseMessage response)
|
||||
public async Task<AutoDiscoverySettings> GetAutoDiscoverySettings(AutoDiscoveryMinimalSettings autoDiscoveryMinimalSettings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
if (autoDiscoveryMinimalSettings == null || string.IsNullOrWhiteSpace(autoDiscoveryMinimalSettings.Email))
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize(content, DomainModelsJsonContext.Default.AutoDiscoverySettings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
if (!TryGetEmailParts(autoDiscoveryMinimalSettings.Email, out var localPart, out var domain))
|
||||
return null;
|
||||
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var settings = await TryGetThunderbirdSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, localPart, cancellationToken).ConfigureAwait(false)
|
||||
?? await TryGetIspdbSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, localPart, cancellationToken).ConfigureAwait(false)
|
||||
?? await TryGetMxBasedSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, localPart, cancellationToken).ConfigureAwait(false)
|
||||
?? await TryGetSrvBasedSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, cancellationToken).ConfigureAwait(false)
|
||||
?? await TryGetGuessedHostSettingsAsync(domain, autoDiscoveryMinimalSettings.Email, cancellationToken).ConfigureAwait(false)
|
||||
?? await GetSettingsFromFiretrustAsync(autoDiscoveryMinimalSettings.Email, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (settings != null && string.IsNullOrWhiteSpace(settings.Domain))
|
||||
{
|
||||
Log.Error(ex, "Failed to deserialize Firetrust response.");
|
||||
settings.Domain = domain;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
public async Task<Uri> DiscoverCalDavServiceUriAsync(string mailAddress, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryGetEmailParts(mailAddress, out _, out var domain))
|
||||
return null;
|
||||
|
||||
lock (_calDavCacheLock)
|
||||
{
|
||||
if (_calDavUriCache.TryGetValue(domain, out var cachedUri))
|
||||
return cachedUri;
|
||||
}
|
||||
|
||||
var knownProviderUri = TryGetKnownProviderCalDavUri(domain);
|
||||
if (knownProviderUri != null)
|
||||
{
|
||||
CacheCalDavUri(domain, knownProviderUri);
|
||||
return knownProviderUri;
|
||||
}
|
||||
|
||||
foreach (var candidate in GetCalDavCandidates(domain))
|
||||
{
|
||||
var resolved = await TryResolveCalDavEndpointAsync(candidate, cancellationToken).ConfigureAwait(false);
|
||||
if (resolved == null)
|
||||
continue;
|
||||
|
||||
CacheCalDavUri(domain, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoverySettings> TryGetThunderbirdSettingsAsync(
|
||||
string lookupDomain,
|
||||
string email,
|
||||
string localPart,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var endpoint in BuildThunderbirdEndpoints(lookupDomain, email))
|
||||
{
|
||||
var settings = await TryGetSettingsFromXmlEndpointAsync(endpoint, email, localPart, lookupDomain, cancellationToken).ConfigureAwait(false);
|
||||
if (settings != null)
|
||||
return settings;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoverySettings> TryGetIspdbSettingsAsync(
|
||||
string lookupDomain,
|
||||
string email,
|
||||
string localPart,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = $"{ThunderbirdIspdbUrl}{lookupDomain}?emailaddress={Uri.EscapeDataString(email)}";
|
||||
return await TryGetSettingsFromXmlEndpointAsync(endpoint, email, localPart, lookupDomain, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoverySettings> TryGetMxBasedSettingsAsync(
|
||||
string domain,
|
||||
string email,
|
||||
string localPart,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var mxDomains = await GetMxSearchDomainsAsync(domain, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var mxDomain in mxDomains)
|
||||
{
|
||||
if (IgnoreCase.Equals(mxDomain, domain))
|
||||
continue;
|
||||
|
||||
var settings = await TryGetThunderbirdSettingsAsync(mxDomain, email, localPart, cancellationToken).ConfigureAwait(false)
|
||||
?? await TryGetIspdbSettingsAsync(mxDomain, email, localPart, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (settings != null)
|
||||
return settings;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoverySettings> TryGetSrvBasedSettingsAsync(
|
||||
string domain,
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incoming = await TryResolveSrvRecordAsync($"_imaps._tcp.{domain}", "IMAP", "SSL", cancellationToken).ConfigureAwait(false)
|
||||
?? await TryResolveSrvRecordAsync($"_imap._tcp.{domain}", "IMAP", "STARTTLS", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var outgoing = await TryResolveSrvRecordAsync($"_submissions._tcp.{domain}", "SMTP", "SSL", cancellationToken).ConfigureAwait(false)
|
||||
?? await TryResolveSrvRecordAsync($"_submission._tcp.{domain}", "SMTP", "STARTTLS", cancellationToken).ConfigureAwait(false)
|
||||
?? await TryResolveSrvRecordAsync($"_smtp._tcp.{domain}", "SMTP", "STARTTLS", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (incoming == null || outgoing == null)
|
||||
return null;
|
||||
|
||||
incoming.Username = email;
|
||||
outgoing.Username = email;
|
||||
|
||||
return new AutoDiscoverySettings
|
||||
{
|
||||
Domain = domain,
|
||||
Settings = [incoming, outgoing]
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoverySettings> TryGetGuessedHostSettingsAsync(
|
||||
string domain,
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var imapHost = await GetFirstResolvableHostAsync(
|
||||
[$"imap.{domain}", $"mail.{domain}", domain],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var smtpHost = await GetFirstResolvableHostAsync(
|
||||
[$"smtp.{domain}", $"mail.{domain}", domain],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(imapHost) || string.IsNullOrWhiteSpace(smtpHost))
|
||||
return null;
|
||||
|
||||
return new AutoDiscoverySettings
|
||||
{
|
||||
Domain = domain,
|
||||
Settings =
|
||||
[
|
||||
new AutoDiscoveryProviderSetting
|
||||
{
|
||||
Protocol = "IMAP",
|
||||
Address = imapHost,
|
||||
Port = 993,
|
||||
Secure = "SSL",
|
||||
Username = email
|
||||
},
|
||||
new AutoDiscoveryProviderSetting
|
||||
{
|
||||
Protocol = "SMTP",
|
||||
Address = smtpHost,
|
||||
Port = 587,
|
||||
Secure = "STARTTLS",
|
||||
Username = email
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoverySettings> TryGetSettingsFromXmlEndpointAsync(
|
||||
string endpoint,
|
||||
string email,
|
||||
string localPart,
|
||||
string domain,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return ParseThunderbirdSettings(content, email, localPart, domain);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug(ex, "Failed to read autodiscovery XML endpoint {Endpoint}", endpoint);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static AutoDiscoverySettings ParseThunderbirdSettings(string xmlContent, string email, string localPart, string domain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xmlContent))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var document = XDocument.Parse(xmlContent);
|
||||
|
||||
var incomingServers = document
|
||||
.Descendants()
|
||||
.Where(e => e.Name.LocalName == "incomingServer")
|
||||
.Where(e => string.Equals((string)e.Attribute("type"), "imap", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(e => ParseThunderbirdServer(e, "IMAP", email, localPart, domain))
|
||||
.Where(e => e != null)
|
||||
.ToList();
|
||||
|
||||
var outgoingServers = document
|
||||
.Descendants()
|
||||
.Where(e => e.Name.LocalName == "outgoingServer")
|
||||
.Where(e => string.Equals((string)e.Attribute("type"), "smtp", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(e => ParseThunderbirdServer(e, "SMTP", email, localPart, domain))
|
||||
.Where(e => e != null)
|
||||
.ToList();
|
||||
|
||||
var bestIncoming = SelectBestServerSetting(incomingServers);
|
||||
var bestOutgoing = SelectBestServerSetting(outgoingServers);
|
||||
|
||||
if (bestIncoming == null || bestOutgoing == null)
|
||||
return null;
|
||||
|
||||
return new AutoDiscoverySettings
|
||||
{
|
||||
Domain = domain,
|
||||
Settings = [bestIncoming, bestOutgoing]
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug(ex, "Failed to parse Thunderbird autodiscovery XML.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static AutoDiscoveryProviderSetting ParseThunderbirdServer(
|
||||
XElement serverElement,
|
||||
string protocol,
|
||||
string email,
|
||||
string localPart,
|
||||
string domain)
|
||||
{
|
||||
var address = ResolveTemplate(GetElementValue(serverElement, "hostname"), email, localPart, domain);
|
||||
var username = ResolveTemplate(GetElementValue(serverElement, "username"), email, localPart, domain);
|
||||
var socketType = ResolveTemplate(GetElementValue(serverElement, "socketType"), email, localPart, domain);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
return null;
|
||||
|
||||
if (!int.TryParse(GetElementValue(serverElement, "port"), out var port))
|
||||
return null;
|
||||
|
||||
return new AutoDiscoveryProviderSetting
|
||||
{
|
||||
Protocol = protocol,
|
||||
Address = address.Trim(),
|
||||
Port = port,
|
||||
Secure = socketType?.Trim() ?? string.Empty,
|
||||
Username = string.IsNullOrWhiteSpace(username) ? email : username.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
private static AutoDiscoveryProviderSetting SelectBestServerSetting(IReadOnlyCollection<AutoDiscoveryProviderSetting> settings)
|
||||
{
|
||||
if (settings == null || settings.Count == 0)
|
||||
return null;
|
||||
|
||||
return settings
|
||||
.OrderByDescending(GetSecurityScore)
|
||||
.ThenBy(s => s.Port)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int GetSecurityScore(AutoDiscoveryProviderSetting setting)
|
||||
{
|
||||
if (setting == null)
|
||||
return 0;
|
||||
|
||||
var secureValue = setting.Secure ?? string.Empty;
|
||||
|
||||
if (secureValue.Contains("SSL", StringComparison.OrdinalIgnoreCase) ||
|
||||
secureValue.Contains("TLS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (secureValue.Contains("STARTTLS", StringComparison.OrdinalIgnoreCase))
|
||||
return 2;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static string GetElementValue(XElement element, string localName)
|
||||
=> element.Elements().FirstOrDefault(e => e.Name.LocalName == localName)?.Value;
|
||||
|
||||
private static string ResolveTemplate(string value, string email, string localPart, string domain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
|
||||
return value
|
||||
.Replace("%EMAILADDRESS%", email, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%EMAILLOCALPART%", localPart, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%EMAILDOMAIN%", domain, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildThunderbirdEndpoints(string domain, string email)
|
||||
{
|
||||
var escapedEmail = Uri.EscapeDataString(email);
|
||||
yield return $"https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={escapedEmail}";
|
||||
yield return $"https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress={escapedEmail}";
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoverySettings> GetSettingsFromFiretrustAsync(string mailAddress, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync($"{FiretrustUrl}{Uri.EscapeDataString(mailAddress)}", cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Logger.Warning("Firetrust autodiscovery failed with status {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize(content, DomainModelsJsonContext.Default.AutoDiscoverySettings);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to deserialize Firetrust autodiscovery response.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AutoDiscoveryProviderSetting> TryResolveSrvRecordAsync(
|
||||
string queryName,
|
||||
string protocol,
|
||||
string secureHint,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var records = await QueryDnsAsync(queryName, "SRV", cancellationToken).ConfigureAwait(false);
|
||||
var srvRecord = records
|
||||
.Select(ParseSrvRecord)
|
||||
.Where(r => r != null)
|
||||
.OrderBy(r => r.Priority)
|
||||
.ThenBy(r => r.Weight)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (srvRecord == null)
|
||||
return null;
|
||||
|
||||
return new AutoDiscoveryProviderSetting
|
||||
{
|
||||
Protocol = protocol,
|
||||
Address = srvRecord.Target,
|
||||
Port = srvRecord.Port,
|
||||
Secure = secureHint
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> GetMxSearchDomainsAsync(string domain, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<string> { domain };
|
||||
var records = await QueryDnsAsync(domain, "MX", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var hosts = records
|
||||
.Select(ParseMxRecord)
|
||||
.Where(r => r != null)
|
||||
.OrderBy(r => r.Preference)
|
||||
.Select(r => r.Target)
|
||||
.Distinct(IgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
foreach (var candidateDomain in BuildDomainCandidatesFromHost(host))
|
||||
{
|
||||
if (!results.Contains(candidateDomain, IgnoreCase))
|
||||
{
|
||||
results.Add(candidateDomain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<string> GetFirstResolvableHostAsync(IEnumerable<string> hostCandidates, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var host in hostCandidates.Where(h => !string.IsNullOrWhiteSpace(h)).Distinct(IgnoreCase))
|
||||
{
|
||||
if (await HasAnyDnsAddressRecordAsync(host, cancellationToken).ConfigureAwait(false))
|
||||
return host;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> HasAnyDnsAddressRecordAsync(string host, CancellationToken cancellationToken)
|
||||
{
|
||||
var aRecords = await QueryDnsAsync(host, "A", cancellationToken).ConfigureAwait(false);
|
||||
if (aRecords.Count > 0)
|
||||
return true;
|
||||
|
||||
var aaaaRecords = await QueryDnsAsync(host, "AAAA", cancellationToken).ConfigureAwait(false);
|
||||
return aaaaRecords.Count > 0;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> QueryDnsAsync(string queryName, string queryType, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{GoogleDnsResolveUrl}?name={Uri.EscapeDataString(queryName)}&type={Uri.EscapeDataString(queryType)}";
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return Array.Empty<string>();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!document.RootElement.TryGetProperty("Answer", out var answerArray) ||
|
||||
answerArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var values = new List<string>();
|
||||
|
||||
foreach (var answer in answerArray.EnumerateArray())
|
||||
{
|
||||
if (answer.TryGetProperty("data", out var dataNode) && dataNode.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var data = dataNode.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(data))
|
||||
values.Add(data);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug(ex, "DNS-over-HTTPS query failed for {QueryName} ({Type})", queryName, queryType);
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Uri> TryResolveCalDavEndpointAsync(Uri candidate, CancellationToken cancellationToken)
|
||||
{
|
||||
var getResult = await ProbeCalDavEndpointAsync(candidate, HttpMethod.Get, cancellationToken).ConfigureAwait(false);
|
||||
if (getResult != null)
|
||||
return getResult;
|
||||
|
||||
return await ProbeCalDavEndpointAsync(candidate, OptionsMethod, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<Uri> ProbeCalDavEndpointAsync(Uri uri, HttpMethod method, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, uri);
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (TryResolveRedirectTarget(uri, response, out var redirectTarget))
|
||||
return redirectTarget;
|
||||
|
||||
if (!IsPossibleCalDavEndpoint(response))
|
||||
return null;
|
||||
|
||||
return response.RequestMessage?.RequestUri ?? uri;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug(ex, "CalDAV probe failed for {Uri} with method {Method}", uri, method);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPossibleCalDavEndpoint(HttpResponseMessage response)
|
||||
{
|
||||
if (response == null)
|
||||
return false;
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.MultiStatus)
|
||||
return true;
|
||||
|
||||
var hasDavHeader = response.Headers.Contains("DAV");
|
||||
var hasDavMethod = response.Headers.TryGetValues("Allow", out var allowValues)
|
||||
&& allowValues.Any(value =>
|
||||
value.Contains("PROPFIND", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Contains("REPORT", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.MethodNotAllowed)
|
||||
return hasDavHeader || hasDavMethod;
|
||||
|
||||
return response.IsSuccessStatusCode && (hasDavHeader || hasDavMethod);
|
||||
}
|
||||
|
||||
private static bool TryResolveRedirectTarget(Uri baseUri, HttpResponseMessage response, out Uri resolvedUri)
|
||||
{
|
||||
resolvedUri = null;
|
||||
|
||||
if (response == null || !IsRedirectStatusCode(response.StatusCode))
|
||||
return false;
|
||||
|
||||
if (response.Headers.Location == null)
|
||||
return false;
|
||||
|
||||
resolvedUri = response.Headers.Location.IsAbsoluteUri
|
||||
? response.Headers.Location
|
||||
: new Uri(baseUri, response.Headers.Location);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsRedirectStatusCode(HttpStatusCode statusCode)
|
||||
=> statusCode == HttpStatusCode.MovedPermanently
|
||||
|| statusCode == HttpStatusCode.Found
|
||||
|| statusCode == HttpStatusCode.RedirectMethod
|
||||
|| statusCode == HttpStatusCode.TemporaryRedirect
|
||||
|| (int)statusCode == 308;
|
||||
|
||||
private static Uri TryGetKnownProviderCalDavUri(string domain)
|
||||
{
|
||||
if (domain.EndsWith("icloud.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
domain.EndsWith("me.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
domain.EndsWith("mac.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new Uri("https://caldav.icloud.com/");
|
||||
}
|
||||
|
||||
if (domain.Contains("yahoo.", StringComparison.OrdinalIgnoreCase) ||
|
||||
domain.EndsWith("aol.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new Uri("https://caldav.calendar.yahoo.com/");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<Uri> GetCalDavCandidates(string domain)
|
||||
{
|
||||
foreach (var candidateDomain in BuildDomainCandidatesFromHost(domain))
|
||||
{
|
||||
yield return new Uri($"https://{candidateDomain}/.well-known/caldav");
|
||||
yield return new Uri($"https://caldav.{candidateDomain}/");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildDomainCandidatesFromHost(string hostOrDomain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hostOrDomain))
|
||||
yield break;
|
||||
|
||||
var normalized = hostOrDomain.Trim().TrimEnd('.');
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
yield break;
|
||||
|
||||
yield return normalized;
|
||||
|
||||
var segments = normalized.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length > 2)
|
||||
{
|
||||
yield return string.Join('.', segments.Skip(1));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetEmailParts(string email, out string localPart, out string domain)
|
||||
{
|
||||
localPart = null;
|
||||
domain = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return false;
|
||||
|
||||
var separatorIndex = email.IndexOf('@');
|
||||
if (separatorIndex <= 0 || separatorIndex >= email.Length - 1)
|
||||
return false;
|
||||
|
||||
localPart = email[..separatorIndex];
|
||||
domain = email[(separatorIndex + 1)..];
|
||||
return !string.IsNullOrWhiteSpace(localPart) && !string.IsNullOrWhiteSpace(domain);
|
||||
}
|
||||
|
||||
private void CacheCalDavUri(string domain, Uri calDavUri)
|
||||
{
|
||||
lock (_calDavCacheLock)
|
||||
{
|
||||
_calDavUriCache[domain] = calDavUri;
|
||||
}
|
||||
}
|
||||
|
||||
private static SrvRecord ParseSrvRecord(string rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
return null;
|
||||
|
||||
var parts = rawValue.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 4)
|
||||
return null;
|
||||
|
||||
if (!ushort.TryParse(parts[0], out var priority) ||
|
||||
!ushort.TryParse(parts[1], out var weight) ||
|
||||
!int.TryParse(parts[2], out var port))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var target = parts[3].Trim().TrimEnd('.');
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
return null;
|
||||
|
||||
return new SrvRecord(priority, weight, port, target);
|
||||
}
|
||||
|
||||
private static MxRecord ParseMxRecord(string rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
return null;
|
||||
|
||||
var parts = rawValue.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2 || !ushort.TryParse(parts[0], out var preference))
|
||||
return null;
|
||||
|
||||
var target = parts[1].Trim().TrimEnd('.');
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
return null;
|
||||
|
||||
return new MxRecord(preference, target);
|
||||
}
|
||||
|
||||
private sealed record SrvRecord(ushort Priority, ushort Weight, int Port, string Target);
|
||||
private sealed record MxRecord(ushort Preference, string Target);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Synchronizers.Errors;
|
||||
using Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for handling Gmail synchronizer errors.
|
||||
/// Registers and routes errors to appropriate handlers.
|
||||
/// </summary>
|
||||
public class GmailSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IGmailSynchronizerErrorHandlerFactory
|
||||
{
|
||||
public bool CanHandle(SynchronizerErrorContext error) => CanHandle(error);
|
||||
|
||||
public Task HandleAsync(SynchronizerErrorContext error) => HandleErrorAsync(error);
|
||||
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);
|
||||
RegisterHandler(rateLimitHandler); // Most generic rate limit handler last
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Synchronizers.Errors;
|
||||
using Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for handling IMAP synchronizer errors.
|
||||
/// Registers and routes errors to appropriate handlers.
|
||||
/// </summary>
|
||||
public class ImapSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IImapSynchronizerErrorHandlerFactory
|
||||
{
|
||||
public ImapSynchronizerErrorHandlingFactory(
|
||||
ImapConnectionLostHandler connectionLostHandler,
|
||||
ImapAuthenticationFailedHandler authFailedHandler,
|
||||
EntityNotFoundHandler entityNotFoundHandler,
|
||||
ImapFolderNotFoundHandler folderNotFoundHandler,
|
||||
ImapProtocolErrorHandler protocolErrorHandler)
|
||||
{
|
||||
// Order matters - more specific handlers should be registered first
|
||||
RegisterHandler(authFailedHandler);
|
||||
RegisterHandler(entityNotFoundHandler);
|
||||
RegisterHandler(folderNotFoundHandler);
|
||||
RegisterHandler(connectionLostHandler);
|
||||
RegisterHandler(protocolErrorHandler); // Most generic, registered last
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Net.Smtp;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
@@ -11,69 +9,32 @@ namespace Wino.Core.Services;
|
||||
|
||||
public class ImapTestService : IImapTestService
|
||||
{
|
||||
public const string ProtocolLogFileName = "ImapProtocolLog.log";
|
||||
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
private readonly IApplicationConfiguration _appInitializerService;
|
||||
|
||||
private Stream _protocolLogStream;
|
||||
|
||||
public ImapTestService(IPreferencesService preferencesService, IApplicationConfiguration appInitializerService)
|
||||
public ImapTestService()
|
||||
{
|
||||
_preferencesService = preferencesService;
|
||||
_appInitializerService = appInitializerService;
|
||||
}
|
||||
|
||||
private void EnsureProtocolLogFileExists()
|
||||
{
|
||||
// Create new file for protocol logger.
|
||||
var localAppFolderPath = _appInitializerService.ApplicationDataFolderPath;
|
||||
|
||||
var logFile = Path.Combine(localAppFolderPath, ProtocolLogFileName);
|
||||
|
||||
if (File.Exists(logFile))
|
||||
File.Delete(logFile);
|
||||
|
||||
_protocolLogStream = File.Create(logFile);
|
||||
}
|
||||
|
||||
public async Task TestImapConnectionAsync(CustomServerInformation serverInformation, bool allowSSLHandShake)
|
||||
{
|
||||
try
|
||||
var poolOptions = ImapClientPoolOptions.CreateTestPool(serverInformation);
|
||||
|
||||
using (var clientPool = new ImapClientPool(poolOptions)
|
||||
{
|
||||
EnsureProtocolLogFileExists();
|
||||
|
||||
var poolOptions = ImapClientPoolOptions.CreateTestPool(serverInformation, _protocolLogStream);
|
||||
|
||||
var clientPool = new ImapClientPool(poolOptions)
|
||||
{
|
||||
ThrowOnSSLHandshakeCallback = !allowSSLHandShake
|
||||
};
|
||||
|
||||
using (clientPool)
|
||||
{
|
||||
// This call will make sure that everything is authenticated + connected successfully.
|
||||
var client = await clientPool.GetClientAsync();
|
||||
|
||||
clientPool.Release(client);
|
||||
}
|
||||
|
||||
// Test SMTP connectivity.
|
||||
using var smtpClient = new SmtpClient();
|
||||
|
||||
if (!smtpClient.IsConnected)
|
||||
await smtpClient.ConnectAsync(serverInformation.OutgoingServer, int.Parse(serverInformation.OutgoingServerPort), MailKit.Security.SecureSocketOptions.Auto);
|
||||
|
||||
if (!smtpClient.IsAuthenticated)
|
||||
await smtpClient.AuthenticateAsync(serverInformation.OutgoingServerUsername, serverInformation.OutgoingServerPassword);
|
||||
}
|
||||
catch (Exception)
|
||||
ThrowOnSSLHandshakeCallback = !allowSSLHandShake
|
||||
})
|
||||
{
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_protocolLogStream?.Dispose();
|
||||
// This call will make sure that everything is authenticated + connected successfully.
|
||||
var client = await clientPool.GetClientAsync();
|
||||
|
||||
clientPool.Release(client);
|
||||
}
|
||||
|
||||
// Test SMTP connectivity.
|
||||
using var smtpClient = new SmtpClient();
|
||||
|
||||
if (!smtpClient.IsConnected)
|
||||
await smtpClient.ConnectAsync(serverInformation.OutgoingServer, int.Parse(serverInformation.OutgoingServerPort), MailKit.Security.SecureSocketOptions.Auto);
|
||||
|
||||
if (!smtpClient.IsAuthenticated)
|
||||
await smtpClient.AuthenticateAsync(serverInformation.OutgoingServerUsername, serverInformation.OutgoingServerPassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Synchronizers.Errors;
|
||||
using Wino.Core.Synchronizers.Errors.Outlook;
|
||||
|
||||
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);
|
||||
RegisterHandler(deltaTokenExpiredHandler);
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error) => CanHandle(error);
|
||||
|
||||
public Task HandleAsync(SynchronizerErrorContext error) => HandleErrorAsync(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Retry;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Executes operations with automatic retry and error handling support.
|
||||
/// Implements exponential backoff with jitter.
|
||||
/// </summary>
|
||||
public class RetryExecutor : IRetryExecutor
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<RetryExecutor>();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<T> ExecuteWithRetryAsync<T>(
|
||||
Func<CancellationToken, Task<T>> operation,
|
||||
RetryPolicy policy,
|
||||
Func<Exception, SynchronizerErrorContext> errorContextFactory,
|
||||
ISynchronizerErrorHandlerFactory errorHandler = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
ArgumentNullException.ThrowIfNull(errorContextFactory);
|
||||
|
||||
int attempt = 0;
|
||||
Exception lastException = null;
|
||||
|
||||
while (attempt <= policy.MaxRetries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
return await operation(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw; // Don't retry on cancellation
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
attempt++;
|
||||
|
||||
var errorContext = errorContextFactory(ex);
|
||||
errorContext.RetryCount = attempt;
|
||||
errorContext.MaxRetries = policy.MaxRetries;
|
||||
|
||||
// Let the error handler process the error first
|
||||
if (errorHandler != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var handled = await errorHandler.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||
if (handled)
|
||||
{
|
||||
_logger.Debug("Error handled by error handler, severity: {Severity}", errorContext.Severity);
|
||||
}
|
||||
}
|
||||
catch (Exception handlerEx)
|
||||
{
|
||||
_logger.Warning(handlerEx, "Error handler threw an exception");
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should retry based on error severity
|
||||
if (errorContext.Severity == SynchronizerErrorSeverity.Fatal ||
|
||||
errorContext.Severity == SynchronizerErrorSeverity.AuthRequired)
|
||||
{
|
||||
_logger.Warning(ex, "Non-retryable error (severity: {Severity}), failing immediately", errorContext.Severity);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (errorContext.Severity == SynchronizerErrorSeverity.Recoverable)
|
||||
{
|
||||
_logger.Debug(ex, "Recoverable error, not retrying but allowing continuation");
|
||||
throw;
|
||||
}
|
||||
|
||||
// Transient error - check if we have retries left
|
||||
if (attempt > policy.MaxRetries)
|
||||
{
|
||||
_logger.Warning(ex, "All {MaxRetries} retries exhausted", policy.MaxRetries);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Calculate delay and wait
|
||||
var delay = errorContext.RetryDelay ?? policy.GetDelay(attempt);
|
||||
_logger.Debug("Retry attempt {Attempt}/{MaxRetries} after {Delay}ms delay for error: {ErrorMessage}",
|
||||
attempt, policy.MaxRetries, delay.TotalMilliseconds, ex.Message);
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but just in case
|
||||
throw lastException ?? new InvalidOperationException("Retry loop completed without result");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ExecuteWithRetryAsync(
|
||||
Func<CancellationToken, Task> operation,
|
||||
RetryPolicy policy,
|
||||
Func<Exception, SynchronizerErrorContext> errorContextFactory,
|
||||
ISynchronizerErrorHandlerFactory errorHandler = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ExecuteWithRetryAsync(
|
||||
async ct =>
|
||||
{
|
||||
await operation(ct).ConfigureAwait(false);
|
||||
return true; // Dummy return value
|
||||
},
|
||||
policy,
|
||||
errorContextFactory,
|
||||
errorHandler,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<T> ExecuteWithRetryAsync<T>(
|
||||
Func<CancellationToken, Task<T>> operation,
|
||||
Func<Exception, SynchronizerErrorContext> errorContextFactory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ExecuteWithRetryAsync(
|
||||
operation,
|
||||
RetryPolicy.Default,
|
||||
errorContextFactory,
|
||||
null,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
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;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton manager that handles synchronizer instances and operations for all accounts.
|
||||
/// Replaces the old WinoServerConnectionManager functionality.
|
||||
/// </summary>
|
||||
public class SynchronizationManager : ISynchronizationManager
|
||||
{
|
||||
private static readonly Lazy<SynchronizationManager> _instance = new(() => new SynchronizationManager());
|
||||
public static SynchronizationManager Instance => _instance.Value;
|
||||
|
||||
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>();
|
||||
|
||||
private SynchronizerFactory _concreteSynchronizerFactory;
|
||||
private IImapTestService _imapTestService;
|
||||
private IAccountService _accountService;
|
||||
private IAuthenticationProvider _authenticationProvider;
|
||||
private INotificationBuilder _notificationBuilder;
|
||||
|
||||
private bool _isInitialized = false;
|
||||
|
||||
private SynchronizationManager() { }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the SynchronizationManager with required dependencies.
|
||||
/// This must be called before using any other methods.
|
||||
/// Note: Synchronizers are created lazily to avoid requiring window handles during app initialization.
|
||||
/// </summary>
|
||||
/// <param name="synchronizerFactory">Factory for creating synchronizers</param>
|
||||
/// <param name="imapTestService">Service for testing IMAP connectivity</param>
|
||||
/// <param name="accountService">Service for account operations</param>
|
||||
/// <param name="authenticationProvider">Provider for OAuth authentication</param>
|
||||
public async Task InitializeAsync(ISynchronizerFactory synchronizerFactory,
|
||||
IImapTestService imapTestService,
|
||||
IAccountService accountService,
|
||||
INotificationBuilder notificationBuilder,
|
||||
IAuthenticationProvider authenticationProvider)
|
||||
{
|
||||
await _initializationSemaphore.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
if (_isInitialized) return;
|
||||
|
||||
_concreteSynchronizerFactory = synchronizerFactory as SynchronizerFactory ?? throw new ArgumentException("SynchronizerFactory must be the concrete implementation");
|
||||
_imapTestService = imapTestService ?? throw new ArgumentNullException(nameof(imapTestService));
|
||||
_accountService = accountService ?? throw new ArgumentNullException(nameof(accountService));
|
||||
_authenticationProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider));
|
||||
_notificationBuilder = notificationBuilder ?? throw new ArgumentNullException(nameof(notificationBuilder));
|
||||
|
||||
// DO NOT create synchronizers here to avoid requiring window handles during initialization.
|
||||
// Synchronizers will be created lazily when first accessed via GetOrCreateSynchronizerAsync.
|
||||
|
||||
_isInitialized = true;
|
||||
_logger.Information("SynchronizationManager dependencies initialized. Synchronizers will be created lazily.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initializationSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests IMAP server connectivity for the given server information.
|
||||
/// </summary>
|
||||
/// <param name="serverInformation">Server information to test</param>
|
||||
/// <param name="allowSSLHandshake">Whether to allow SSL handshake</param>
|
||||
/// <returns>Test results indicating success or failure with details</returns>
|
||||
public async Task<ImapConnectivityTestResults> TestImapConnectivityAsync(CustomServerInformation serverInformation, bool allowSSLHandshake)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.Information("Testing IMAP connectivity for {Server}:{Port}",
|
||||
serverInformation.IncomingServer,
|
||||
serverInformation.IncomingServerPort);
|
||||
|
||||
await _imapTestService.TestImapConnectionAsync(serverInformation, allowSSLHandshake);
|
||||
|
||||
_logger.Information("IMAP connectivity test successful");
|
||||
return ImapConnectivityTestResults.Success();
|
||||
}
|
||||
catch (ImapTestSSLCertificateException sslTestException)
|
||||
{
|
||||
_logger.Warning("IMAP connectivity test requires SSL certificate confirmation");
|
||||
return ImapConnectivityTestResults.CertificateUIRequired(
|
||||
sslTestException.Issuer,
|
||||
sslTestException.ExpirationDateString,
|
||||
sslTestException.ValidFromDateString);
|
||||
}
|
||||
catch (ImapClientPoolException clientPoolException)
|
||||
{
|
||||
_logger.Error(clientPoolException, "IMAP connectivity test failed");
|
||||
return ImapConnectivityTestResults.Failure(clientPoolException);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.Error(exception, "IMAP connectivity test failed");
|
||||
return ImapConnectivityTestResults.Failure(exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a new mail synchronization for the given account.
|
||||
/// </summary>
|
||||
/// <param name="options">Mail synchronization options</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Synchronization result</returns>
|
||||
public async Task<MailSynchronizationResult> SynchronizeMailAsync(MailSynchronizationOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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)
|
||||
{
|
||||
_logger.Error("Could not find or create synchronizer for account {AccountId}", options.AccountId);
|
||||
|
||||
return MailSynchronizationResult.Failed(new Exception("Can't create/get synchronizer."));
|
||||
}
|
||||
|
||||
_logger.Information("Starting mail synchronization for account {AccountId} with type {SyncType}",
|
||||
options.AccountId, options.Type);
|
||||
|
||||
var accountCancellationSource = _accountSynchronizationCancellationSources.GetOrAdd(options.AccountId, _ => new CancellationTokenSource());
|
||||
using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
accountCancellationSource.Token);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await synchronizer.SynchronizeMailsAsync(options, linkedCancellationTokenSource.Token);
|
||||
|
||||
_logger.Information("Mail synchronization completed for account {AccountId} with state {State}",
|
||||
options.AccountId, result.CompletedState);
|
||||
|
||||
// Create notifications.
|
||||
if (result.DownloadedMessages?.Any() ?? false)
|
||||
await _notificationBuilder.CreateNotificationsAsync(result.DownloadedMessages);
|
||||
|
||||
await _notificationBuilder.UpdateTaskbarIconBadgeAsync();
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Information("Mail synchronization canceled for account {AccountId}", options.AccountId);
|
||||
return MailSynchronizationResult.Canceled;
|
||||
}
|
||||
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);
|
||||
|
||||
return MailSynchronizationResult.Failed(authEx);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Mail synchronization failed for account {AccountId}", options.AccountId);
|
||||
return MailSynchronizationResult.Failed(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there is an ongoing synchronization for the given account.
|
||||
/// </summary>
|
||||
/// <param name="accountId">Account ID to check</param>
|
||||
/// <returns>True if synchronization is ongoing, false otherwise</returns>
|
||||
public bool IsAccountSynchronizing(Guid accountId)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
if (_synchronizerCache.TryGetValue(accountId, out var synchronizer))
|
||||
{
|
||||
return synchronizer.State == AccountSynchronizerState.Synchronizing ||
|
||||
synchronizer.State == AccountSynchronizerState.ExecutingRequests;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a request to the corresponding account's synchronizer with optional synchronization triggering.
|
||||
/// Automatically determines whether to trigger mail or calendar synchronization based on the request type.
|
||||
/// </summary>
|
||||
/// <param name="request">Request to queue</param>
|
||||
/// <param name="accountId">Account ID to queue the request for</param>
|
||||
/// <param name="triggerSynchronization">Whether to automatically trigger synchronization after queuing the request</param>
|
||||
public async Task QueueRequestAsync(IRequestBase request, Guid accountId, bool triggerSynchronization)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
var synchronizer = await GetOrCreateSynchronizerAsync(accountId);
|
||||
if (synchronizer == null)
|
||||
{
|
||||
_logger.Error("Could not find or create synchronizer for account {AccountId} to queue request", accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Debug("Queuing request {RequestType} for account {AccountId}",
|
||||
request.GetType().Name, accountId);
|
||||
|
||||
synchronizer.QueueRequest(request);
|
||||
|
||||
if (triggerSynchronization)
|
||||
{
|
||||
// Determine if this is a calendar or mail operation
|
||||
bool isCalendarOperation = request is ICalendarActionRequest;
|
||||
|
||||
if (isCalendarOperation)
|
||||
{
|
||||
// Trigger calendar synchronization
|
||||
_logger.Debug("Triggering calendar synchronization to execute queued request for account {AccountId}", accountId);
|
||||
|
||||
var calendarSyncOptions = new CalendarSynchronizationOptions()
|
||||
{
|
||||
AccountId = accountId
|
||||
};
|
||||
|
||||
// Trigger synchronization asynchronously without waiting for completion
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await SynchronizeCalendarAsync(calendarSyncOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to execute calendar synchronization after queuing request for account {AccountId}", accountId);
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Trigger mail synchronization (includes mail and folder operations)
|
||||
_logger.Debug("Triggering mail synchronization to execute queued request for account {AccountId}", accountId);
|
||||
|
||||
var mailSyncOptions = new MailSynchronizationOptions()
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = MailSynchronizationType.ExecuteRequests
|
||||
};
|
||||
|
||||
// Trigger synchronization asynchronously without waiting for completion
|
||||
// This matches the pattern used in WinoRequestDelegator
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await SynchronizeMailAsync(mailSyncOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to execute mail synchronization after queuing request for account {AccountId}", accountId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles folder synchronization for the given account.
|
||||
/// </summary>
|
||||
/// <param name="accountId">Account ID to synchronize folders for</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Synchronization result</returns>
|
||||
public async Task<MailSynchronizationResult> SynchronizeFoldersAsync(Guid accountId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
var options = new MailSynchronizationOptions
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = MailSynchronizationType.FoldersOnly
|
||||
};
|
||||
|
||||
return await SynchronizeMailAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles alias synchronization for the given account.
|
||||
/// </summary>
|
||||
/// <param name="accountId">Account ID to synchronize aliases for</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Synchronization result</returns>
|
||||
public async Task<MailSynchronizationResult> SynchronizeAliasesAsync(Guid accountId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
var options = new MailSynchronizationOptions
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = MailSynchronizationType.Alias
|
||||
};
|
||||
|
||||
return await SynchronizeMailAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles profile synchronization for the given account.
|
||||
/// </summary>
|
||||
/// <param name="accountId">Account ID to synchronize profile for</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Synchronization result</returns>
|
||||
public async Task<MailSynchronizationResult> SynchronizeProfileAsync(Guid accountId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
var options = new MailSynchronizationOptions
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = MailSynchronizationType.UpdateProfile
|
||||
};
|
||||
|
||||
return await SynchronizeMailAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles calendar synchronization for the given account.
|
||||
/// </summary>
|
||||
/// <param name="options">Calendar synchronization options</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <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)
|
||||
{
|
||||
_logger.Error("Could not find or create synchronizer for account {AccountId}", options.AccountId);
|
||||
return CalendarSynchronizationResult.Failed;
|
||||
}
|
||||
|
||||
_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,
|
||||
accountCancellationSource.Token);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await synchronizer.SynchronizeCalendarEventsAsync(options, linkedCancellationTokenSource.Token);
|
||||
|
||||
_logger.Information("Calendar synchronization completed for account {AccountId} with state {State}",
|
||||
options.AccountId, result.CompletedState);
|
||||
|
||||
// TODO: Create notifications for new calendar events when INotificationBuilder supports it
|
||||
// if (result.DownloadedEvents?.Any() ?? false)
|
||||
// await _notificationBuilder.CreateCalendarNotificationsAsync(result.DownloadedEvents);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Information("Calendar synchronization canceled for account {AccountId}", options.AccountId);
|
||||
return CalendarSynchronizationResult.Canceled;
|
||||
}
|
||||
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);
|
||||
|
||||
return CalendarSynchronizationResult.Failed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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>
|
||||
/// Downloads a MIME message for the given mail item.
|
||||
/// </summary>
|
||||
/// <param name="mailItem">Mail item to download</param>
|
||||
/// <param name="accountId">Account ID that owns the mail item</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Downloaded MIME content path</returns>
|
||||
public async Task<string> DownloadMimeMessageAsync(MailCopy mailItem, Guid accountId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
var synchronizer = await GetOrCreateSynchronizerAsync(accountId);
|
||||
if (synchronizer == null)
|
||||
{
|
||||
_logger.Error("Could not find or create synchronizer for account {AccountId} to download MIME", accountId);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.Debug("Downloading MIME message for mail item {MailItemId}", mailItem.Id);
|
||||
|
||||
try
|
||||
{
|
||||
await synchronizer.DownloadMissingMimeMessageAsync(mailItem, null, cancellationToken);
|
||||
return mailItem.Id.ToString(); // Return some identifier, actual implementation might be different
|
||||
}
|
||||
catch (SynchronizerEntityNotFoundException)
|
||||
{
|
||||
_logger.Warning("MIME message for mail item {MailItemId} no longer exists on server. Removed locally.", mailItem.Id);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to download MIME message for mail item {MailItemId}", mailItem.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a calendar attachment using the appropriate synchronizer.
|
||||
/// </summary>
|
||||
public async Task DownloadCalendarAttachmentAsync(
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem,
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment,
|
||||
string localFilePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
if (calendarItem == null)
|
||||
throw new ArgumentNullException(nameof(calendarItem));
|
||||
|
||||
if (attachment == null)
|
||||
throw new ArgumentNullException(nameof(attachment));
|
||||
|
||||
var accountId = calendarItem.AssignedCalendar?.AccountId ?? Guid.Empty;
|
||||
if (accountId == Guid.Empty)
|
||||
throw new InvalidOperationException("Calendar item does not have an assigned account.");
|
||||
|
||||
var synchronizer = await GetOrCreateSynchronizerAsync(accountId);
|
||||
|
||||
if (synchronizer == null)
|
||||
{
|
||||
_logger.Error("Could not find or create synchronizer for account {AccountId} to download calendar attachment", accountId);
|
||||
throw new InvalidOperationException("No synchronizer available for downloading calendar attachment.");
|
||||
}
|
||||
|
||||
_logger.Debug("Downloading calendar attachment {AttachmentId} for calendar item {CalendarItemId}",
|
||||
attachment.Id, calendarItem.Id);
|
||||
|
||||
try
|
||||
{
|
||||
await synchronizer.DownloadCalendarAttachmentAsync(
|
||||
calendarItem,
|
||||
attachment,
|
||||
localFilePath,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to download calendar attachment {AttachmentId}", attachment.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new synchronizer for a newly added account.
|
||||
/// </summary>
|
||||
/// <param name="account">Account to create synchronizer for</param>
|
||||
/// <returns>Created synchronizer</returns>
|
||||
public IWinoSynchronizerBase CreateSynchronizerForAccount(MailAccount account)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
try
|
||||
{
|
||||
var synchronizer = _concreteSynchronizerFactory.CreateNewSynchronizer(account);
|
||||
_synchronizerCache.TryAdd(account.Id, synchronizer);
|
||||
|
||||
_logger.Information("Created new synchronizer for account {AccountName} ({AccountId})",
|
||||
account.Name, account.Id);
|
||||
|
||||
return synchronizer;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to create synchronizer for account {AccountName} ({AccountId})",
|
||||
account.Name, account.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels all in-flight synchronizations for the given account.
|
||||
/// </summary>
|
||||
/// <param name="accountId">Account ID to cancel synchronizations for</param>
|
||||
public Task CancelSynchronizationsAsync(Guid accountId)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
if (_accountSynchronizationCancellationSources.TryRemove(accountId, out var cancellationSource))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!cancellationSource.IsCancellationRequested)
|
||||
{
|
||||
cancellationSource.Cancel();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancellationSource.Dispose();
|
||||
}
|
||||
|
||||
_logger.Information("Canceled ongoing synchronizations for account {AccountId}", accountId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys the synchronizer for the given account.
|
||||
/// </summary>
|
||||
/// <param name="accountId">Account ID to destroy synchronizer for</param>
|
||||
public async Task DestroySynchronizerAsync(Guid accountId)
|
||||
{
|
||||
EnsureInitialized();
|
||||
await CancelSynchronizationsAsync(accountId);
|
||||
|
||||
if (_synchronizerCache.TryRemove(accountId, out var synchronizer))
|
||||
{
|
||||
try
|
||||
{
|
||||
await synchronizer.KillSynchronizerAsync();
|
||||
_logger.Information("Destroyed synchronizer for account {AccountId}", accountId);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Information("Synchronizer destruction canceled for account {AccountId}", accountId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to destroy synchronizer for account {AccountId}", accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all cached synchronizers.
|
||||
/// </summary>
|
||||
/// <returns>Collection of all cached synchronizers</returns>
|
||||
public IEnumerable<IWinoSynchronizerBase> GetAllSynchronizers()
|
||||
{
|
||||
EnsureInitialized();
|
||||
return _synchronizerCache.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a synchronizer for the given account ID.
|
||||
/// </summary>
|
||||
/// <param name="accountId">Account ID</param>
|
||||
/// <returns>Synchronizer if found, null otherwise</returns>
|
||||
public async Task<IWinoSynchronizerBase> GetSynchronizerAsync(Guid accountId)
|
||||
{
|
||||
EnsureInitialized();
|
||||
return await GetOrCreateSynchronizerAsync(accountId);
|
||||
}
|
||||
|
||||
private async Task<IWinoSynchronizerBase> GetOrCreateSynchronizerAsync(Guid accountId)
|
||||
{
|
||||
if (_synchronizerCache.TryGetValue(accountId, out var existingSynchronizer))
|
||||
{
|
||||
return existingSynchronizer;
|
||||
}
|
||||
|
||||
// Try to create a new synchronizer if not found
|
||||
var account = await _accountService.GetAccountAsync(accountId);
|
||||
if (account != null)
|
||||
{
|
||||
return CreateSynchronizerForAccount(account);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles OAuth authentication for the specified provider.
|
||||
/// </summary>
|
||||
/// <param name="providerType">The mail provider type to authenticate</param>
|
||||
/// <param name="account">Optional account to authenticate (null for initial authentication)</param>
|
||||
/// <param name="proposeCopyAuthorizationURL">Whether to propose copying auth URL for Gmail</param>
|
||||
/// <returns>Token information containing access token and username</returns>
|
||||
public async Task<TokenInformationEx> HandleAuthorizationAsync(MailProviderType providerType,
|
||||
MailAccount account = null,
|
||||
bool proposeCopyAuthorizationURL = false)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
try
|
||||
{
|
||||
var authenticator = _authenticationProvider.GetAuthenticator(providerType);
|
||||
|
||||
// Some users are having issues with Gmail authentication.
|
||||
// Their browsers may never launch to complete authentication.
|
||||
// Offer to copy auth url for them to complete it manually.
|
||||
// Redirection will occur to the app and the token will be saved.
|
||||
if (proposeCopyAuthorizationURL && authenticator is IGmailAuthenticator gmailAuthenticator)
|
||||
{
|
||||
gmailAuthenticator.ProposeCopyAuthURL = true;
|
||||
}
|
||||
|
||||
TokenInformationEx tokenInfo;
|
||||
|
||||
if (account != null)
|
||||
{
|
||||
// Get token for existing account (may trigger interactive auth if token is expired)
|
||||
tokenInfo = await authenticator.GetTokenInformationAsync(account);
|
||||
_logger.Information("Retrieved token for existing account {AccountAddress}", account.Address);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Initial authentication request - there is no account to get token for
|
||||
// This will always trigger interactive authentication
|
||||
tokenInfo = await authenticator.GenerateTokenInformationAsync(null);
|
||||
_logger.Information("Generated new token for {ProviderType} authentication", providerType);
|
||||
}
|
||||
|
||||
return tokenInfo;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to handle authorization for {ProviderType}", providerType);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureInitialized()
|
||||
{
|
||||
if (!_isInitialized)
|
||||
{
|
||||
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,33 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service responsible for initializing the SynchronizationManager during app startup.
|
||||
/// </summary>
|
||||
public class SynchronizationManagerInitializer : IInitializeAsync
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public SynchronizationManagerInitializer(IServiceProvider serviceProvider)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
var synchronizerFactory = _serviceProvider.GetRequiredService<ISynchronizerFactory>();
|
||||
var imapTestService = _serviceProvider.GetRequiredService<IImapTestService>();
|
||||
var accountService = _serviceProvider.GetRequiredService<IAccountService>();
|
||||
var authenticationProvider = _serviceProvider.GetRequiredService<IAuthenticationProvider>();
|
||||
var notificationBuilder = _serviceProvider.GetRequiredService<INotificationBuilder>();
|
||||
|
||||
// Cast to concrete type to access CreateNewSynchronizer method
|
||||
var concreteSynchronizerFactory = synchronizerFactory as SynchronizerFactory;
|
||||
|
||||
await SynchronizationManager.Instance.InitializeAsync(concreteSynchronizerFactory, imapTestService, accountService, notificationBuilder, authenticationProvider);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration.Processors;
|
||||
using Wino.Core.Synchronizers.ImapSync;
|
||||
using Wino.Core.Synchronizers.Mail;
|
||||
|
||||
namespace Wino.Core.Services;
|
||||
@@ -13,39 +14,48 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
private bool isInitialized = false;
|
||||
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
|
||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||
private readonly IOutlookSynchronizerErrorHandlerFactory _outlookSynchronizerErrorHandlerFactory;
|
||||
private readonly IGmailSynchronizerErrorHandlerFactory _gmailSynchronizerErrorHandlerFactory;
|
||||
private readonly IImapSynchronizerErrorHandlerFactory _imapSynchronizerErrorHandlerFactory;
|
||||
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
|
||||
private readonly IGmailChangeProcessor _gmailChangeProcessor;
|
||||
private readonly IImapChangeProcessor _imapChangeProcessor;
|
||||
private readonly IOutlookAuthenticator _outlookAuthenticator;
|
||||
private readonly IGmailAuthenticator _gmailAuthenticator;
|
||||
private readonly IAuthenticationProvider _authenticationProvider;
|
||||
private readonly UnifiedImapSynchronizer _unifiedImapSynchronizer;
|
||||
private readonly ICalDavClient _calDavClient;
|
||||
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
||||
private readonly ICalendarService _calendarService;
|
||||
|
||||
private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
|
||||
|
||||
public SynchronizerFactory(IOutlookChangeProcessor outlookChangeProcessor,
|
||||
IGmailChangeProcessor gmailChangeProcessor,
|
||||
IImapChangeProcessor imapChangeProcessor,
|
||||
IOutlookAuthenticator outlookAuthenticator,
|
||||
IGmailAuthenticator gmailAuthenticator,
|
||||
IAuthenticationProvider authenticationProvider,
|
||||
IAccountService accountService,
|
||||
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
|
||||
IApplicationConfiguration applicationConfiguration,
|
||||
IOutlookSynchronizerErrorHandlerFactory outlookSynchronizerErrorHandlerFactory,
|
||||
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory)
|
||||
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory,
|
||||
IImapSynchronizerErrorHandlerFactory imapSynchronizerErrorHandlerFactory,
|
||||
UnifiedImapSynchronizer unifiedImapSynchronizer,
|
||||
ICalDavClient calDavClient,
|
||||
IAutoDiscoveryService autoDiscoveryService,
|
||||
ICalendarService calendarService)
|
||||
{
|
||||
_outlookChangeProcessor = outlookChangeProcessor;
|
||||
_gmailChangeProcessor = gmailChangeProcessor;
|
||||
_imapChangeProcessor = imapChangeProcessor;
|
||||
_outlookAuthenticator = outlookAuthenticator;
|
||||
_gmailAuthenticator = gmailAuthenticator;
|
||||
_authenticationProvider = authenticationProvider;
|
||||
_accountService = accountService;
|
||||
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
|
||||
_applicationConfiguration = applicationConfiguration;
|
||||
_outlookSynchronizerErrorHandlerFactory = outlookSynchronizerErrorHandlerFactory;
|
||||
_gmailSynchronizerErrorHandlerFactory = gmailSynchronizerErrorHandlerFactory;
|
||||
_imapSynchronizerErrorHandlerFactory = imapSynchronizerErrorHandlerFactory;
|
||||
_unifiedImapSynchronizer = unifiedImapSynchronizer;
|
||||
_calDavClient = calDavClient;
|
||||
_autoDiscoveryService = autoDiscoveryService;
|
||||
_calendarService = calendarService;
|
||||
}
|
||||
|
||||
public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId)
|
||||
@@ -75,11 +85,13 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
switch (providerType)
|
||||
{
|
||||
case Domain.Enums.MailProviderType.Outlook:
|
||||
return new OutlookSynchronizer(mailAccount, _outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory);
|
||||
var outlookAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Outlook) as IOutlookAuthenticator;
|
||||
return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory);
|
||||
case Domain.Enums.MailProviderType.Gmail:
|
||||
return new GmailSynchronizer(mailAccount, _gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
|
||||
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
|
||||
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
|
||||
case Domain.Enums.MailProviderType.IMAP4:
|
||||
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _imapSynchronizationStrategyProvider, _applicationConfiguration);
|
||||
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory, _calDavClient, _autoDiscoveryService, _calendarService);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Services.Threading;
|
||||
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
public class ThreadingStrategyProvider : IThreadingStrategyProvider
|
||||
{
|
||||
private readonly OutlookThreadingStrategy _outlookThreadingStrategy;
|
||||
private readonly GmailThreadingStrategy _gmailThreadingStrategy;
|
||||
private readonly ImapThreadingStrategy _imapThreadStrategy;
|
||||
|
||||
public ThreadingStrategyProvider(OutlookThreadingStrategy outlookThreadingStrategy,
|
||||
GmailThreadingStrategy gmailThreadingStrategy,
|
||||
ImapThreadingStrategy imapThreadStrategy)
|
||||
{
|
||||
_outlookThreadingStrategy = outlookThreadingStrategy;
|
||||
_gmailThreadingStrategy = gmailThreadingStrategy;
|
||||
_imapThreadStrategy = imapThreadStrategy;
|
||||
}
|
||||
|
||||
public IThreadingStrategy GetStrategy(MailProviderType mailProviderType)
|
||||
{
|
||||
return mailProviderType switch
|
||||
{
|
||||
MailProviderType.Outlook => _outlookThreadingStrategy,
|
||||
MailProviderType.Gmail => _gmailThreadingStrategy,
|
||||
_ => _imapThreadStrategy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,33 +5,41 @@ using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Helpers;
|
||||
using Wino.Core.Requests.Calendar;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Messaging.Server;
|
||||
using Wino.Messaging.UI;
|
||||
|
||||
namespace Wino.Core.Services;
|
||||
|
||||
public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
{
|
||||
private readonly IWinoRequestProcessor _winoRequestProcessor;
|
||||
private readonly IWinoServerConnectionManager _winoServerConnectionManager;
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly IMailDialogService _dialogService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ICalendarService _calendarService;
|
||||
|
||||
public WinoRequestDelegator(IWinoRequestProcessor winoRequestProcessor,
|
||||
IWinoServerConnectionManager winoServerConnectionManager,
|
||||
IFolderService folderService,
|
||||
IMailDialogService dialogService)
|
||||
IMailDialogService dialogService,
|
||||
IAccountService accountService,
|
||||
ICalendarService calendarService)
|
||||
{
|
||||
_winoRequestProcessor = winoRequestProcessor;
|
||||
_winoServerConnectionManager = winoServerConnectionManager;
|
||||
_folderService = folderService;
|
||||
_dialogService = dialogService;
|
||||
_accountService = accountService;
|
||||
_calendarService = calendarService;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(MailOperationPreperationRequest request)
|
||||
@@ -82,14 +90,20 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
var accountIds = requests.GroupBy(a => a.Item.AssignedAccount.Id);
|
||||
|
||||
// Queue requests for each account and start synchronization.
|
||||
foreach (var accountId in accountIds)
|
||||
foreach (var accountGroup in accountIds)
|
||||
{
|
||||
foreach (var accountRequest in accountId)
|
||||
foreach (var accountRequest in accountGroup)
|
||||
{
|
||||
await QueueRequestAsync(accountRequest, accountId.Key);
|
||||
await QueueRequestAsync(accountRequest, accountGroup.Key);
|
||||
}
|
||||
|
||||
await QueueSynchronizationAsync(accountId.Key);
|
||||
var account = accountGroup.First().Item.AssignedAccount;
|
||||
var actionItems = SynchronizationActionHelper.CreateActionItems(accountGroup, accountGroup.Key, account.Name);
|
||||
|
||||
if (actionItems.Count > 0)
|
||||
WeakReferenceMessenger.Default.Send(new SynchronizationActionsAdded(accountGroup.Key, account.Name, actionItems));
|
||||
|
||||
await QueueSynchronizationAsync(accountGroup.Key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,55 +131,147 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
if (request == null) return;
|
||||
|
||||
await QueueRequestAsync(request, accountId);
|
||||
await SendSyncActionsAddedAsync([request], accountId);
|
||||
await QueueSynchronizationAsync(accountId);
|
||||
|
||||
if (folderRequest.Action is FolderOperation.Delete or FolderOperation.CreateSubFolder)
|
||||
{
|
||||
await QueueFoldersOnlySynchronizationAsync(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(DraftPreparationRequest draftPreperationRequest)
|
||||
{
|
||||
var request = new CreateDraftRequest(draftPreperationRequest);
|
||||
var accountId = draftPreperationRequest.Account.Id;
|
||||
|
||||
await QueueRequestAsync(request, draftPreperationRequest.Account.Id);
|
||||
await QueueSynchronizationAsync(draftPreperationRequest.Account.Id);
|
||||
await QueueRequestAsync(request, accountId);
|
||||
await SendSyncActionsAddedAsync([request], accountId, draftPreperationRequest.Account.Name);
|
||||
await QueueSynchronizationAsync(accountId);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(SendDraftPreparationRequest sendDraftPreperationRequest)
|
||||
{
|
||||
var request = new SendDraftRequest(sendDraftPreperationRequest);
|
||||
var account = sendDraftPreperationRequest.MailItem.AssignedAccount;
|
||||
|
||||
await QueueRequestAsync(request, sendDraftPreperationRequest.MailItem.AssignedAccount.Id);
|
||||
await QueueSynchronizationAsync(sendDraftPreperationRequest.MailItem.AssignedAccount.Id);
|
||||
await QueueRequestAsync(request, account.Id);
|
||||
await SendSyncActionsAddedAsync([request], account.Id, account.Name);
|
||||
await QueueSynchronizationAsync(account.Id);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
|
||||
{
|
||||
if (calendarPreparationRequest == null)
|
||||
return;
|
||||
|
||||
IRequestBase request = calendarPreparationRequest.Operation switch
|
||||
{
|
||||
CalendarSynchronizerOperation.CreateEvent => await CreateCalendarEventRequestAsync(calendarPreparationRequest).ConfigureAwait(false),
|
||||
CalendarSynchronizerOperation.DeleteEvent => new DeleteCalendarEventRequest(calendarPreparationRequest.CalendarItem),
|
||||
CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
|
||||
CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
|
||||
CalendarSynchronizerOperation.TentativeEvent => new TentativeEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
|
||||
CalendarSynchronizerOperation.UpdateEvent => new UpdateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees)
|
||||
{
|
||||
OriginalItem = calendarPreparationRequest.OriginalItem,
|
||||
OriginalAttendees = calendarPreparationRequest.OriginalAttendees
|
||||
},
|
||||
_ => throw new NotImplementedException($"Calendar operation {calendarPreparationRequest.Operation} is not implemented yet.")
|
||||
};
|
||||
|
||||
if (request == null)
|
||||
return;
|
||||
|
||||
var accountId = calendarPreparationRequest.Operation == CalendarSynchronizerOperation.CreateEvent
|
||||
? calendarPreparationRequest.ComposeResult.AccountId
|
||||
: calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId;
|
||||
var accountName = calendarPreparationRequest.Operation == CalendarSynchronizerOperation.CreateEvent
|
||||
? null
|
||||
: calendarPreparationRequest.CalendarItem.AssignedCalendar.MailAccount?.Name;
|
||||
|
||||
await QueueRequestAsync(request, accountId);
|
||||
await SendSyncActionsAddedAsync([request], accountId, accountName);
|
||||
await QueueCalendarSynchronizationAsync(accountId);
|
||||
}
|
||||
|
||||
private async Task<IRequestBase> CreateCalendarEventRequestAsync(CalendarOperationPreparationRequest calendarPreparationRequest)
|
||||
{
|
||||
var composeResult = calendarPreparationRequest.ComposeResult
|
||||
?? throw new InvalidOperationException("Create event requests require a compose result.");
|
||||
var assignedCalendar = await _calendarService.GetAccountCalendarAsync(composeResult.CalendarId).ConfigureAwait(false);
|
||||
|
||||
if (assignedCalendar == null)
|
||||
throw new InvalidOperationException($"Calendar {composeResult.CalendarId} could not be resolved.");
|
||||
|
||||
return new CreateCalendarEventRequest(composeResult, assignedCalendar);
|
||||
}
|
||||
|
||||
private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage)
|
||||
{
|
||||
// For Outlook accounts, declined events are deleted by the server after synchronization.
|
||||
// Use OutlookDeclineEventRequest to handle UI removal.
|
||||
if (calendarItem.AssignedCalendar?.MailAccount?.ProviderType == MailProviderType.Outlook)
|
||||
{
|
||||
return new OutlookDeclineEventRequest(calendarItem, responseMessage);
|
||||
}
|
||||
|
||||
return new DeclineEventRequest(calendarItem, responseMessage);
|
||||
}
|
||||
|
||||
private async Task QueueRequestAsync(IRequestBase request, Guid accountId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureServerConnectedAsync();
|
||||
await _winoServerConnectionManager.QueueRequestAsync(request, accountId);
|
||||
}
|
||||
catch (WinoServerException serverException)
|
||||
{
|
||||
_dialogService.InfoBarMessage("Wino Server Exception", serverException.Message, InfoBarMessageType.Error);
|
||||
}
|
||||
// Don't trigger synchronization for individual requests - we'll trigger it once for all requests
|
||||
await SynchronizationManager.Instance.QueueRequestAsync(request, accountId, triggerSynchronization: false);
|
||||
}
|
||||
|
||||
private async Task QueueSynchronizationAsync(Guid accountId)
|
||||
private Task QueueSynchronizationAsync(Guid accountId)
|
||||
{
|
||||
await EnsureServerConnectedAsync();
|
||||
|
||||
var options = new MailSynchronizationOptions()
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = MailSynchronizationType.ExecuteRequests
|
||||
};
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options, SynchronizationSource.Client));
|
||||
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task EnsureServerConnectedAsync()
|
||||
private Task QueueFoldersOnlySynchronizationAsync(Guid accountId)
|
||||
{
|
||||
if (_winoServerConnectionManager.Status == WinoServerConnectionStatus.Connected) return;
|
||||
var options = new MailSynchronizationOptions()
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = MailSynchronizationType.FoldersOnly
|
||||
};
|
||||
|
||||
await _winoServerConnectionManager.ConnectAsync();
|
||||
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SendSyncActionsAddedAsync(IEnumerable<IRequestBase> requests, Guid accountId, string accountName = null)
|
||||
{
|
||||
if (accountName == null)
|
||||
{
|
||||
var account = await _accountService.GetAccountAsync(accountId);
|
||||
accountName = account?.Name ?? string.Empty;
|
||||
}
|
||||
|
||||
var actionItems = SynchronizationActionHelper.CreateActionItems(requests, accountId, accountName);
|
||||
|
||||
if (actionItems.Count > 0)
|
||||
WeakReferenceMessenger.Default.Send(new SynchronizationActionsAdded(accountId, accountName, actionItems));
|
||||
}
|
||||
|
||||
private Task QueueCalendarSynchronizationAsync(Guid accountId)
|
||||
{
|
||||
var options = new CalendarSynchronizationOptions()
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = CalendarSynchronizationType.ExecuteRequests
|
||||
};
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new NewCalendarSynchronizationRequested(options));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ public class WinoRequestProcessor : IWinoRequestProcessor
|
||||
/// </summary>
|
||||
private readonly List<ToggleRequestRule> _toggleRequestRules =
|
||||
[
|
||||
new ToggleRequestRule(MailOperation.MarkAsRead, MailOperation.MarkAsUnread, new System.Func<IMailItem, bool>((item) => item.IsRead)),
|
||||
new ToggleRequestRule(MailOperation.MarkAsUnread, MailOperation.MarkAsRead, new System.Func<IMailItem, bool>((item) => !item.IsRead)),
|
||||
new ToggleRequestRule(MailOperation.SetFlag, MailOperation.ClearFlag, new System.Func<IMailItem, bool>((item) => item.IsFlagged)),
|
||||
new ToggleRequestRule(MailOperation.ClearFlag, MailOperation.SetFlag, new System.Func<IMailItem, bool>((item) => !item.IsFlagged)),
|
||||
new ToggleRequestRule(MailOperation.MarkAsRead, MailOperation.MarkAsUnread, new System.Func<MailCopy, bool>((item) => item.IsRead)),
|
||||
new ToggleRequestRule(MailOperation.MarkAsUnread, MailOperation.MarkAsRead, new System.Func<MailCopy, bool>((item) => !item.IsRead)),
|
||||
new ToggleRequestRule(MailOperation.SetFlag, MailOperation.ClearFlag, new System.Func<MailCopy, bool>((item) => item.IsFlagged)),
|
||||
new ToggleRequestRule(MailOperation.ClearFlag, MailOperation.SetFlag, new System.Func<MailCopy, bool>((item) => !item.IsFlagged)),
|
||||
];
|
||||
|
||||
public WinoRequestProcessor(IFolderService folderService,
|
||||
@@ -94,7 +94,7 @@ public class WinoRequestProcessor : IWinoRequestProcessor
|
||||
var requests = new List<IMailActionRequest>();
|
||||
|
||||
// TODO: Fix: Collection was modified; enumeration operation may not execute
|
||||
foreach (var item in preperationRequest.MailItems)
|
||||
foreach (var item in preperationRequest.MailItems.ToList())
|
||||
{
|
||||
var singleRequest = await GetSingleRequestAsync(item, action, moveTargetStructure, preperationRequest.ToggleExecution);
|
||||
|
||||
@@ -255,15 +255,29 @@ public class WinoRequestProcessor : IWinoRequestProcessor
|
||||
change = new MarkFolderAsReadRequest(folder, unreadItems);
|
||||
|
||||
break;
|
||||
//case FolderOperation.Delete:
|
||||
// var isConfirmed = await _dialogService.ShowConfirmationDialogAsync($"'{folderStructure.FolderName}' is going to be deleted. Do you want to continue?", "Are you sure?", "Yes delete.");
|
||||
case FolderOperation.Delete:
|
||||
var deleteQuestion = string.Format(Translator.DialogMessage_DeleteAccountConfirmationMessage, folder.FolderName);
|
||||
var shouldDelete = await _dialogService.ShowConfirmationDialogAsync(deleteQuestion, Translator.FolderOperation_Delete, Translator.FolderOperation_Delete);
|
||||
|
||||
// if (isConfirmed)
|
||||
// change = new DeleteFolderRequest(accountId, folderStructure.RemoteFolderId, folderStructure.FolderId);
|
||||
if (shouldDelete)
|
||||
{
|
||||
change = new DeleteFolderRequest(folder);
|
||||
}
|
||||
|
||||
// break;
|
||||
//default:
|
||||
// throw new NotImplementedException();
|
||||
break;
|
||||
case FolderOperation.CreateSubFolder:
|
||||
var subFolderName = await _dialogService.ShowTextInputDialogAsync(
|
||||
string.Empty,
|
||||
Translator.FolderOperation_CreateSubFolder,
|
||||
Translator.DialogMessage_RenameFolderMessage,
|
||||
Translator.FolderOperation_CreateSubFolder);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subFolderName))
|
||||
{
|
||||
change = new CreateSubFolderRequest(folder, subFolderName.Trim());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return change;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
@@ -13,12 +16,16 @@ using Wino.Messaging.UI;
|
||||
|
||||
namespace Wino.Core.Synchronizers;
|
||||
|
||||
public abstract class BaseSynchronizer<TBaseRequest> : IBaseSynchronizer
|
||||
public abstract partial class BaseSynchronizer<TBaseRequest> : ObservableObject, IBaseSynchronizer
|
||||
{
|
||||
protected SemaphoreSlim synchronizationSemaphore = new(1);
|
||||
protected CancellationToken activeSynchronizationCancellationToken;
|
||||
|
||||
protected List<IRequestBase> changeRequestQueue = [];
|
||||
private readonly ConcurrentDictionary<Guid, byte> _pendingMailOperationIds = new();
|
||||
private readonly ConcurrentDictionary<Guid, byte> _pendingCalendarOperationIds = new();
|
||||
protected readonly IMessenger Messenger;
|
||||
|
||||
public MailAccount Account { get; }
|
||||
|
||||
private AccountSynchronizerState state;
|
||||
@@ -29,20 +36,142 @@ public abstract class BaseSynchronizer<TBaseRequest> : IBaseSynchronizer
|
||||
{
|
||||
state = value;
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new AccountSynchronizerStateChanged(Account.Id, value));
|
||||
// Send state changed message with current progress information
|
||||
Messenger.Send(new AccountSynchronizerStateChanged(
|
||||
Account.Id,
|
||||
value,
|
||||
TotalItemsToSync,
|
||||
RemainingItemsToSync,
|
||||
SynchronizationStatus));
|
||||
}
|
||||
}
|
||||
|
||||
protected BaseSynchronizer(MailAccount account)
|
||||
/// <summary>
|
||||
/// Current synchronization status message.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial string SynchronizationStatus { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Total items to download/sync in current operation.
|
||||
/// 0 means no active download or indeterminate progress.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial int TotalItemsToSync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Remaining items to download/sync in current operation.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial int RemainingItemsToSync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Calculated progress percentage (0-100) based on TotalItemsToSync and RemainingItemsToSync.
|
||||
/// Returns -1 for indeterminate progress (when both are 0).
|
||||
/// </summary>
|
||||
public double SynchronizationProgress
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TotalItemsToSync == 0 || RemainingItemsToSync == 0)
|
||||
return -1; // Indeterminate
|
||||
|
||||
return ((double)(TotalItemsToSync - RemainingItemsToSync) / TotalItemsToSync) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
protected BaseSynchronizer(MailAccount account, IMessenger messenger)
|
||||
{
|
||||
Account = account;
|
||||
Messenger = messenger ?? WeakReferenceMessenger.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets synchronization progress to default state.
|
||||
/// </summary>
|
||||
protected void ResetSyncProgress()
|
||||
{
|
||||
TotalItemsToSync = 0;
|
||||
RemainingItemsToSync = 0;
|
||||
SynchronizationStatus = string.Empty;
|
||||
OnPropertyChanged(nameof(SynchronizationProgress));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates synchronization progress with current item counts.
|
||||
/// </summary>
|
||||
/// <param name="total">Total items to sync</param>
|
||||
/// <param name="remaining">Remaining items to sync</param>
|
||||
/// <param name="status">Optional status message</param>
|
||||
protected void UpdateSyncProgress(int total, int remaining, string status = "")
|
||||
{
|
||||
TotalItemsToSync = total;
|
||||
RemainingItemsToSync = remaining;
|
||||
SynchronizationStatus = status;
|
||||
OnPropertyChanged(nameof(SynchronizationProgress));
|
||||
|
||||
// Send progress update message
|
||||
Messenger.Send(new AccountSynchronizerStateChanged(
|
||||
Account.Id,
|
||||
State,
|
||||
TotalItemsToSync,
|
||||
RemainingItemsToSync,
|
||||
SynchronizationStatus));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a single request to be executed in the next synchronization.
|
||||
/// </summary>
|
||||
/// <param name="request">Request to execute.</param>
|
||||
public void QueueRequest(IRequestBase request) => changeRequestQueue.Add(request);
|
||||
public void QueueRequest(IRequestBase request)
|
||||
{
|
||||
changeRequestQueue.Add(request);
|
||||
TrackQueuedRequest(request);
|
||||
}
|
||||
|
||||
public bool HasPendingOperation(Guid mailUniqueId) => _pendingMailOperationIds.ContainsKey(mailUniqueId);
|
||||
|
||||
public IReadOnlyCollection<Guid> GetPendingOperationUniqueIds() => _pendingMailOperationIds.Keys.ToArray();
|
||||
|
||||
public bool HasPendingCalendarOperation(Guid calendarItemId) => _pendingCalendarOperationIds.ContainsKey(calendarItemId);
|
||||
|
||||
protected void TrackQueuedRequest(IRequestBase request)
|
||||
{
|
||||
if (request is IMailActionRequest mailActionRequest)
|
||||
{
|
||||
_pendingMailOperationIds.TryAdd(mailActionRequest.Item.UniqueId, 0);
|
||||
}
|
||||
|
||||
if (request is ICalendarActionRequest calendarActionRequest)
|
||||
{
|
||||
if (calendarActionRequest.LocalCalendarItemId.HasValue)
|
||||
{
|
||||
_pendingCalendarOperationIds.TryAdd(calendarActionRequest.LocalCalendarItemId.Value, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void UntrackProcessedRequest(IRequestBase request)
|
||||
{
|
||||
if (request is IMailActionRequest mailActionRequest)
|
||||
{
|
||||
_pendingMailOperationIds.TryRemove(mailActionRequest.Item.UniqueId, out _);
|
||||
}
|
||||
|
||||
if (request is ICalendarActionRequest calendarActionRequest)
|
||||
{
|
||||
if (calendarActionRequest.LocalCalendarItemId.HasValue)
|
||||
{
|
||||
_pendingCalendarOperationIds.TryRemove(calendarActionRequest.LocalCalendarItemId.Value, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void UntrackProcessedRequests(IEnumerable<IRequestBase> requests)
|
||||
{
|
||||
foreach (var request in requests)
|
||||
UntrackProcessedRequest(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs existing queued requests in the queue.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// Generic handler for 404 (Not Found) errors across all synchronizers.
|
||||
/// When a resource is already gone on the server, this handler applies
|
||||
/// the intended change locally instead of throwing.
|
||||
/// Works for all mail actions, folder actions, and batch operations.
|
||||
/// </summary>
|
||||
public class EntityNotFoundHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<EntityNotFoundHandler>();
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IFolderService _folderService;
|
||||
|
||||
public EntityNotFoundHandler(IMailService mailService, IFolderService folderService)
|
||||
{
|
||||
_mailService = mailService;
|
||||
_folderService = folderService;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
if (error.ErrorCode != 404) return false;
|
||||
if (error.RequestBundle == null) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
error.Severity = SynchronizerErrorSeverity.Recoverable;
|
||||
error.Category = SynchronizerErrorCategory.ResourceNotFound;
|
||||
|
||||
var uiRequest = error.RequestBundle.UIChangeRequest;
|
||||
|
||||
// --- Folder actions ---
|
||||
if (uiRequest is IFolderActionRequest folderAction)
|
||||
{
|
||||
_logger.Warning("Entity not found (404) for folder operation {Op} on {RemoteFolderId}. Deleting locally.",
|
||||
folderAction.Operation, folderAction.Folder.RemoteFolderId);
|
||||
|
||||
try
|
||||
{
|
||||
await _folderService.DeleteFolderAsync(
|
||||
folderAction.Folder.MailAccountId,
|
||||
folderAction.Folder.RemoteFolderId).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to delete folder locally after 404.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Individual mail actions ---
|
||||
if (uiRequest is IMailActionRequest mailAction && error.Account != null)
|
||||
{
|
||||
_logger.Warning("Entity not found (404) for mail operation {Op} on {MailId}. Deleting locally.",
|
||||
mailAction.Operation, mailAction.Item.Id);
|
||||
|
||||
// Revert optimistic UI change (e.g. mark-read/flag toggle) before deleting
|
||||
error.RequestBundle.UIChangeRequest?.RevertUIChanges();
|
||||
|
||||
try
|
||||
{
|
||||
await _mailService.DeleteMailAsync(
|
||||
error.Account.Id, mailAction.Item.Id).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to delete mail locally after 404.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Batch requests (can't identify specific item) ---
|
||||
// Mark as recoverable. Next sync will clean up stale items.
|
||||
_logger.Warning("Entity not found (404) for batch operation. Marking as recoverable.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Gmail history ID expiration errors.
|
||||
/// When history is no longer available, resets the account's history ID to force a full resync.
|
||||
/// </summary>
|
||||
public class GmailHistoryExpiredHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailHistoryExpiredHandler>();
|
||||
private readonly IGmailChangeProcessor _gmailChangeProcessor;
|
||||
|
||||
public GmailHistoryExpiredHandler(IGmailChangeProcessor gmailChangeProcessor)
|
||||
{
|
||||
_gmailChangeProcessor = gmailChangeProcessor;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
// Gmail returns 404 when history ID is no longer valid
|
||||
if (error.ErrorCode == 404)
|
||||
{
|
||||
var message = error.ErrorMessage?.ToLowerInvariant() ?? string.Empty;
|
||||
return message.Contains("history") || message.Contains("notfound");
|
||||
}
|
||||
|
||||
if (error.Exception is GoogleApiException googleEx)
|
||||
{
|
||||
if (googleEx.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
var errorMessage = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
return errorMessage.Contains("history") ||
|
||||
errorMessage.Contains("not found") ||
|
||||
errorMessage.Contains("starthistoryid");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail history ID expired for account {AccountName} ({AccountId}). Resetting to force full sync.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
error.Severity = SynchronizerErrorSeverity.Recoverable;
|
||||
error.Category = SynchronizerErrorCategory.ResourceNotFound;
|
||||
|
||||
// Reset the account's synchronization identifier (history ID)
|
||||
if (error.Account != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _gmailChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(
|
||||
error.Account.Id, string.Empty).ConfigureAwait(false);
|
||||
|
||||
_logger.Information("Successfully reset Gmail history ID for account {AccountName}. Next sync will be full sync.",
|
||||
error.Account.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to reset Gmail history ID for account {AccountName}",
|
||||
error.Account.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Gmail API quota exceeded errors (HTTP 403 with quota error).
|
||||
/// This is a more severe rate limit that indicates daily quota exhaustion.
|
||||
/// </summary>
|
||||
public class GmailQuotaExceededHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailQuotaExceededHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
if (error.Exception is GoogleApiException googleEx)
|
||||
{
|
||||
// Quota exceeded usually returns 403
|
||||
if (googleEx.HttpStatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
var errorMessage = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
var errorReason = googleEx.Error?.Errors?.FirstOrDefault()?.Reason?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
return errorMessage.Contains("quota") ||
|
||||
errorMessage.Contains("limit exceeded") ||
|
||||
errorReason.Contains("quota") ||
|
||||
errorReason.Contains("ratelimitexceeded") ||
|
||||
errorReason.Contains("userlimitexceeded");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail API quota exceeded for account {AccountName} ({AccountId}). Sync will be paused.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
// Quota exceeded is more severe - treat as fatal to prevent repeated failures
|
||||
// The user will be notified and sync will resume after quota resets
|
||||
error.Severity = SynchronizerErrorSeverity.Fatal;
|
||||
error.Category = SynchronizerErrorCategory.RateLimit;
|
||||
|
||||
// Suggest a very long delay - quotas typically reset daily
|
||||
error.RetryDelay = TimeSpan.FromHours(1);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Gmail API rate limiting errors (HTTP 429 Too Many Requests).
|
||||
/// Marks the error as transient with appropriate backoff delay.
|
||||
/// </summary>
|
||||
public class GmailRateLimitHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailRateLimitHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
if (error.ErrorCode == 429)
|
||||
return true;
|
||||
|
||||
if (error.Exception is GoogleApiException googleEx)
|
||||
{
|
||||
return googleEx.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests ||
|
||||
(googleEx.Error?.Code == 429);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail API rate limit hit for account {AccountName} ({AccountId}). Operation: {Operation}. Will retry with backoff.",
|
||||
error.Account?.Name, error.Account?.Id, error.OperationType ?? "N/A");
|
||||
|
||||
error.Severity = SynchronizerErrorSeverity.Transient;
|
||||
error.Category = SynchronizerErrorCategory.RateLimit;
|
||||
|
||||
// Gmail rate limits are usually per-user, suggest a longer delay
|
||||
error.RetryDelay = TimeSpan.FromSeconds(10);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles IMAP authentication failures (AuthenticationException, SaslException).
|
||||
/// Marks the error as requiring re-authentication.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
return error.Exception is AuthenticationException ||
|
||||
error.Exception is SaslException ||
|
||||
(error.ErrorMessage?.Contains("authentication", System.StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// No point in retrying auth failures - credentials need to be updated
|
||||
error.RetryDelay = null;
|
||||
|
||||
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,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles IMAP connection loss errors (IOException, SocketException, ServiceNotConnectedException).
|
||||
/// Marks the error as transient for retry with backoff.
|
||||
/// </summary>
|
||||
public class ImapConnectionLostHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapConnectionLostHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.Exception is IOException ||
|
||||
error.Exception is SocketException ||
|
||||
error.Exception is ServiceNotConnectedException ||
|
||||
error.Exception?.InnerException is IOException ||
|
||||
error.Exception?.InnerException is SocketException;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP connection lost for account {AccountName} ({AccountId}). Folder: {FolderName}. Operation: {Operation}. Will retry.",
|
||||
error.Account?.Name, error.Account?.Id, error.FolderName ?? "N/A", error.OperationType ?? "N/A");
|
||||
|
||||
// Mark as transient - the RetryExecutor will handle the retry logic
|
||||
error.Severity = SynchronizerErrorSeverity.Transient;
|
||||
error.Category = SynchronizerErrorCategory.Network;
|
||||
|
||||
// Suggest a reasonable retry delay for connection issues
|
||||
error.RetryDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles IMAP folder not found errors (FolderNotFoundException).
|
||||
/// Deletes the folder locally and allows sync to continue with other folders.
|
||||
/// </summary>
|
||||
public class ImapFolderNotFoundHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapFolderNotFoundHandler>();
|
||||
private readonly IImapChangeProcessor _imapChangeProcessor;
|
||||
|
||||
public ImapFolderNotFoundHandler(IImapChangeProcessor imapChangeProcessor)
|
||||
{
|
||||
_imapChangeProcessor = imapChangeProcessor;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.Exception is FolderNotFoundException ||
|
||||
error.ErrorCode == 404 ||
|
||||
(error.ErrorMessage?.Contains("folder not found", System.StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(error.ErrorMessage?.Contains("mailbox not found", System.StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP folder not found for account {AccountName} ({AccountId}). Folder: {FolderName} ({FolderId}). Removing locally.",
|
||||
error.Account?.Name, error.Account?.Id, error.FolderName, error.FolderId);
|
||||
|
||||
// Mark as recoverable - sync can continue with other folders
|
||||
error.Severity = SynchronizerErrorSeverity.Recoverable;
|
||||
error.Category = SynchronizerErrorCategory.ResourceNotFound;
|
||||
|
||||
// Try to delete the folder locally if we have the folder ID
|
||||
if (error.FolderId.HasValue && error.Account != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the folder's remote ID from the exception if available
|
||||
var remoteId = error.Exception is FolderNotFoundException fnf ? fnf.FolderName : null;
|
||||
|
||||
if (!string.IsNullOrEmpty(remoteId))
|
||||
{
|
||||
await _imapChangeProcessor.DeleteFolderAsync(error.Account.Id, remoteId).ConfigureAwait(false);
|
||||
_logger.Information("Successfully deleted local folder {FolderName} after server deletion.",
|
||||
error.FolderName);
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to delete local folder {FolderName} ({FolderId})",
|
||||
error.FolderName, error.FolderId);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Net.Imap;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles generic IMAP protocol errors (ImapProtocolException, ImapCommandException).
|
||||
/// This is the catch-all handler for IMAP errors not handled by more specific handlers.
|
||||
/// </summary>
|
||||
public class ImapProtocolErrorHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapProtocolErrorHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
// This is a catch-all for IMAP-related exceptions
|
||||
return error.Exception is ImapProtocolException ||
|
||||
error.Exception is ImapCommandException;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
var severity = ClassifyProtocolError(error);
|
||||
var category = SynchronizerErrorCategory.ProtocolError;
|
||||
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP protocol error for account {AccountName} ({AccountId}). Folder: {FolderName}. Operation: {Operation}. Severity: {Severity}",
|
||||
error.Account?.Name, error.Account?.Id, error.FolderName ?? "N/A", error.OperationType ?? "N/A", severity);
|
||||
|
||||
error.Severity = severity;
|
||||
error.Category = category;
|
||||
|
||||
// For transient protocol errors, suggest a retry delay
|
||||
if (severity == SynchronizerErrorSeverity.Transient)
|
||||
{
|
||||
error.RetryDelay = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies the protocol error to determine if it's transient, recoverable, or fatal.
|
||||
/// </summary>
|
||||
private static SynchronizerErrorSeverity ClassifyProtocolError(SynchronizerErrorContext error)
|
||||
{
|
||||
var message = error.ErrorMessage?.ToLowerInvariant() ?? string.Empty;
|
||||
var exMessage = error.Exception?.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
// Check for rate limiting / throttling
|
||||
if (message.Contains("too many") || message.Contains("rate limit") ||
|
||||
message.Contains("throttl") || exMessage.Contains("too many"))
|
||||
{
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// Check for temporary server issues
|
||||
if (message.Contains("try again") || message.Contains("temporary") ||
|
||||
message.Contains("busy") || exMessage.Contains("try again"))
|
||||
{
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// Check for command-specific errors that are usually transient
|
||||
if (error.Exception is ImapCommandException cmdEx)
|
||||
{
|
||||
// NO response usually means the operation failed but can be retried
|
||||
if (cmdEx.Response == ImapCommandResponse.No)
|
||||
{
|
||||
// Unless it's a permanent failure indication
|
||||
if (message.Contains("permanent") || message.Contains("invalid"))
|
||||
{
|
||||
return SynchronizerErrorSeverity.Recoverable;
|
||||
}
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// BAD response usually indicates a protocol violation - don't retry
|
||||
if (cmdEx.Response == ImapCommandResponse.Bad)
|
||||
{
|
||||
return SynchronizerErrorSeverity.Recoverable;
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol exceptions that indicate connection issues
|
||||
if (error.Exception is ImapProtocolException)
|
||||
{
|
||||
// Most protocol exceptions are connection-related and transient
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// Default to recoverable for unknown protocol errors
|
||||
return SynchronizerErrorSeverity.Recoverable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Graph.Models.ODataErrors;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||
|
||||
/// <summary>
|
||||
/// Handles 410 Gone errors for Outlook synchronization, which indicates that delta tokens have expired.
|
||||
/// When this occurs, all local mail cache should be deleted and initial synchronization should be reset.
|
||||
/// </summary>
|
||||
public class DeltaTokenExpiredHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<DeltaTokenExpiredHandler>();
|
||||
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
|
||||
|
||||
public DeltaTokenExpiredHandler(IOutlookChangeProcessor outlookChangeProcessor)
|
||||
{
|
||||
_outlookChangeProcessor = outlookChangeProcessor;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
// Handle 410 Gone responses which indicate delta token expiration
|
||||
return error.ErrorCode == 410 ||
|
||||
(error.Exception is ODataError oDataError && oDataError.ResponseStatusCode == 410) ||
|
||||
(error.Exception is ApiException apiException && apiException.ResponseStatusCode == 410);
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning("Delta token has expired for account {AccountName} ({AccountId}). Deleting all local mail cache and resetting synchronization.",
|
||||
error.Account.Name, error.Account.Id);
|
||||
|
||||
try
|
||||
{
|
||||
// Delete all local mail cache for the account
|
||||
await _outlookChangeProcessor.DeleteUserMailCacheAsync(error.Account.Id).ConfigureAwait(false);
|
||||
|
||||
// Reset the account's delta synchronization identifier
|
||||
await _outlookChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(error.Account.Id, string.Empty).ConfigureAwait(false);
|
||||
|
||||
// Get all folders for the account and reset their delta tokens
|
||||
var folders = await _outlookChangeProcessor.GetLocalFoldersAsync(error.Account.Id).ConfigureAwait(false);
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
// Reset folder delta token to force full re-sync (last 30 days)
|
||||
await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, string.Empty).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.Information("Successfully reset synchronization state for account {AccountName} ({AccountId}). Next sync will download last 30 days.",
|
||||
error.Account.Name, error.Account.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to handle delta token expiration for account {AccountName} ({AccountId})",
|
||||
error.Account.Name, error.Account.Id);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||
@@ -18,7 +18,7 @@ public class ObjectCannotBeDeletedHandler : ISynchronizerErrorHandler
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.ErrorMessage.Contains("ErrorCannotDeleteObject") && error.RequestBundle is HttpRequestBundle<RequestInformation>;
|
||||
return error.ErrorMessage.Contains("Object cannot be deleted.") && error.RequestBundle is HttpRequestBundle<RequestInformation>;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Graph.Models.ODataErrors;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Microsoft Graph throttling responses for Outlook synchronization.
|
||||
/// </summary>
|
||||
public class OutlookRateLimitHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<OutlookRateLimitHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.ErrorCode == 429 ||
|
||||
(error.Exception is ODataError oDataError && oDataError.ResponseStatusCode == 429) ||
|
||||
(error.Exception is ApiException apiException && apiException.ResponseStatusCode == 429);
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Microsoft Graph rate limit hit for account {AccountName} ({AccountId}). Operation: {Operation}.",
|
||||
error.Account?.Name, error.Account?.Id, error.OperationType ?? "N/A");
|
||||
|
||||
error.Severity = SynchronizerErrorSeverity.Transient;
|
||||
error.Category = SynchronizerErrorCategory.RateLimit;
|
||||
error.RetryDelay = TimeSpan.FromSeconds(10);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,132 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4551 CONDSTORE IMAP Synchronization strategy.
|
||||
/// </summary>
|
||||
internal class CondstoreSynchronizer : ImapSynchronizationStrategyBase
|
||||
{
|
||||
public CondstoreSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService)
|
||||
{
|
||||
}
|
||||
|
||||
public async override Task<List<string>> HandleSynchronizationAsync(IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is not WinoImapClient winoClient)
|
||||
throw new ArgumentException("Client must be of type WinoImapClient.", nameof(client));
|
||||
|
||||
if (!client.Capabilities.HasFlag(ImapCapabilities.CondStore))
|
||||
throw new ImapSynchronizerStrategyException("Server does not support CONDSTORE.");
|
||||
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
Folder = folder;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await winoClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var localHighestModSeq = (ulong)folder.HighestModeSeq;
|
||||
|
||||
bool isInitialSynchronization = localHighestModSeq == 0;
|
||||
|
||||
// There are some changes on new messages or flag changes.
|
||||
// Deletions are tracked separately because some servers do not increase
|
||||
// the MODSEQ value for deleted messages.
|
||||
if (remoteFolder.HighestModSeq > localHighestModSeq)
|
||||
{
|
||||
var changedUids = await GetChangedUidsAsync(client, remoteFolder, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get locally exists mails for the returned UIDs.
|
||||
downloadedMessageIds = await HandleChangedUIdsAsync(synchronizer, remoteFolder, changedUids, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
|
||||
await FolderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
catch (FolderNotFoundException)
|
||||
{
|
||||
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (remoteFolder != null)
|
||||
{
|
||||
if (remoteFolder.IsOpen)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal override async Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient winoClient, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var localHighestModSeq = (ulong)Folder.HighestModeSeq;
|
||||
var remoteHighestModSeq = remoteFolder.HighestModSeq;
|
||||
|
||||
// Search for emails with a MODSEQ greater than the last known value.
|
||||
// Use SORT extension if server supports.
|
||||
|
||||
IList<UniqueId> changedUids = null;
|
||||
|
||||
if (winoClient.Capabilities.HasFlag(ImapCapabilities.Sort))
|
||||
{
|
||||
// Highest mod seq must be greater than 0 for SORT.
|
||||
changedUids = await remoteFolder.SortAsync(SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)), [OrderBy.ReverseDate], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// For initial synchronizations, take the first allowed number of items.
|
||||
// For consequtive synchronizations, take all the items. We don't want to miss any changes.
|
||||
// Smaller uid means newer message. For initial sync, we need start taking items from the top.
|
||||
|
||||
bool isInitialSynchronization = localHighestModSeq == 0;
|
||||
|
||||
if (isInitialSynchronization)
|
||||
{
|
||||
changedUids = changedUids.OrderByDescending(a => a.Id).Take((int)synchronizer.InitialMessageDownloadCountPerFolder).ToList();
|
||||
}
|
||||
|
||||
return changedUids;
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using MoreLinq;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Services.Extensions;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
public abstract class ImapSynchronizationStrategyBase : IImapSynchronizerStrategy
|
||||
{
|
||||
// Minimum summary items to Fetch for mail synchronization from IMAP.
|
||||
protected readonly MessageSummaryItems MailSynchronizationFlags =
|
||||
MessageSummaryItems.Flags |
|
||||
MessageSummaryItems.UniqueId |
|
||||
MessageSummaryItems.ThreadId |
|
||||
MessageSummaryItems.EmailId |
|
||||
MessageSummaryItems.Headers |
|
||||
MessageSummaryItems.PreviewText |
|
||||
MessageSummaryItems.GMailThreadId |
|
||||
MessageSummaryItems.References |
|
||||
MessageSummaryItems.ModSeq;
|
||||
|
||||
protected IFolderService FolderService { get; }
|
||||
protected IMailService MailService { get; }
|
||||
protected MailItemFolder Folder { get; set; }
|
||||
|
||||
protected ImapSynchronizationStrategyBase(IFolderService folderService, IMailService mailService)
|
||||
{
|
||||
FolderService = folderService;
|
||||
MailService = mailService;
|
||||
}
|
||||
|
||||
public abstract Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
|
||||
internal abstract Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
|
||||
|
||||
protected async Task<List<string>> HandleChangedUIdsAsync(IImapSynchronizer synchronizer,
|
||||
IMailFolder remoteFolder,
|
||||
IList<UniqueId> changedUids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<string> downloadedMessageIds = new();
|
||||
|
||||
var existingMails = await MailService.GetExistingMailsAsync(Folder.Id, changedUids).ConfigureAwait(false);
|
||||
var existingMailUids = existingMails.Select(m => MailkitClientExtensions.ResolveUidStruct(m.Id)).ToArray();
|
||||
|
||||
// These are the non-existing mails. They will be downloaded + processed.
|
||||
var newMessageIds = changedUids.Except(existingMailUids).ToList();
|
||||
var deletedMessageIds = existingMailUids.Except(changedUids).ToList();
|
||||
|
||||
// Fetch minimum data for the existing mails in one query.
|
||||
var existingFlagData = await remoteFolder.FetchAsync(existingMailUids, MessageSummaryItems.Flags | MessageSummaryItems.UniqueId).ConfigureAwait(false);
|
||||
|
||||
foreach (var update in existingFlagData)
|
||||
{
|
||||
if (update.UniqueId == UniqueId.Invalid)
|
||||
{
|
||||
Log.Warning($"Couldn't fetch UniqueId for the mail. FetchAsync failed.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (update.Flags == null)
|
||||
{
|
||||
Log.Warning($"Couldn't fetch flags for the mail with UID {update.UniqueId.Id}. FetchAsync failed.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingMail = existingMails.FirstOrDefault(m => MailkitClientExtensions.ResolveUidStruct(m.Id).Id == update.UniqueId.Id);
|
||||
|
||||
if (existingMail == null)
|
||||
{
|
||||
Log.Warning($"Couldn't find the mail with UID {update.UniqueId.Id} in the local database. Flag update is ignored.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await HandleMessageFlagsChangeAsync(existingMail, update.Flags.Value).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Fetch the new mails in batch.
|
||||
|
||||
var batchedMessageIds = newMessageIds.Batch(50).ToList();
|
||||
// Create tasks for each batch.
|
||||
foreach (var group in batchedMessageIds)
|
||||
{
|
||||
downloadedMessageIds.AddRange(group.Select(a => MailkitClientExtensions.CreateUid(Folder.Id, a.Id)));
|
||||
|
||||
await DownloadMessagesAsync(synchronizer, remoteFolder, Folder, new UniqueIdSet(group, SortOrder.Ascending), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
protected async Task HandleMessageFlagsChangeAsync(UniqueId? uniqueId, MessageFlags flags)
|
||||
{
|
||||
if (Folder == null) return;
|
||||
if (uniqueId == null) return;
|
||||
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(Folder.Id, uniqueId.Value.Id);
|
||||
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(flags);
|
||||
|
||||
await MailService.ChangeReadStatusAsync(localMailCopyId, isRead).ConfigureAwait(false);
|
||||
await MailService.ChangeFlagStatusAsync(localMailCopyId, isFlagged).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async Task HandleMessageFlagsChangeAsync(MailCopy mailCopy, MessageFlags flags)
|
||||
{
|
||||
if (mailCopy == null) return;
|
||||
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(flags);
|
||||
|
||||
if (isFlagged != mailCopy.IsFlagged)
|
||||
{
|
||||
await MailService.ChangeFlagStatusAsync(mailCopy.Id, isFlagged).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (isRead != mailCopy.IsRead)
|
||||
{
|
||||
await MailService.ChangeReadStatusAsync(mailCopy.Id, isRead).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task HandleMessageDeletedAsync(IList<UniqueId> uniqueIds)
|
||||
{
|
||||
if (Folder == null) return;
|
||||
if (uniqueIds == null || uniqueIds.Count == 0) return;
|
||||
|
||||
foreach (var uniqueId in uniqueIds)
|
||||
{
|
||||
if (uniqueId == null) continue;
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(Folder.Id, uniqueId.Id);
|
||||
|
||||
await MailService.DeleteMailAsync(Folder.MailAccountId, localMailCopyId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected void OnMessagesVanished(object sender, MessagesVanishedEventArgs args)
|
||||
=> HandleMessageDeletedAsync(args.UniqueIds).ConfigureAwait(false);
|
||||
|
||||
protected void OnMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs args)
|
||||
=> HandleMessageFlagsChangeAsync(args.UniqueId, args.Flags).ConfigureAwait(false);
|
||||
|
||||
protected async Task ManageUUIdBasedDeletedMessagesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allUids = (await FolderService.GetKnownUidsForFolderAsync(localFolder.Id)).Select(a => new UniqueId(a)).ToList();
|
||||
|
||||
if (allUids.Count > 0)
|
||||
{
|
||||
var remoteAllUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken);
|
||||
var deletedUids = allUids.Except(remoteAllUids).ToList();
|
||||
|
||||
await HandleMessageDeletedAsync(deletedUids).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DownloadMessagesAsync(IImapSynchronizer synchronizer,
|
||||
IMailFolder folder,
|
||||
MailItemFolder localFolder,
|
||||
UniqueIdSet uniqueIdSet,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var summaries = await folder.FetchAsync(uniqueIdSet, MailSynchronizationFlags, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
var mimeMessage = await folder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage);
|
||||
|
||||
var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, localFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mailPackages != null)
|
||||
{
|
||||
foreach (var package in mailPackages)
|
||||
{
|
||||
// Local draft is mapped. We don't need to create a new mail copy.
|
||||
if (package == null) continue;
|
||||
|
||||
await MailService.CreateMailAsync(localFolder.MailAccountId, package).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using MailKit.Net.Imap;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
internal class ImapSynchronizationStrategyProvider : IImapSynchronizationStrategyProvider
|
||||
{
|
||||
private readonly QResyncSynchronizer _qResyncSynchronizer;
|
||||
private readonly CondstoreSynchronizer _condstoreSynchronizer;
|
||||
private readonly UidBasedSynchronizer _uidBasedSynchronizer;
|
||||
|
||||
public ImapSynchronizationStrategyProvider(QResyncSynchronizer qResyncSynchronizer, CondstoreSynchronizer condstoreSynchronizer, UidBasedSynchronizer uidBasedSynchronizer)
|
||||
{
|
||||
_qResyncSynchronizer = qResyncSynchronizer;
|
||||
_condstoreSynchronizer = condstoreSynchronizer;
|
||||
_uidBasedSynchronizer = uidBasedSynchronizer;
|
||||
}
|
||||
|
||||
public IImapSynchronizerStrategy GetSynchronizationStrategy(IImapClient client)
|
||||
{
|
||||
if (client is not WinoImapClient winoImapClient)
|
||||
throw new System.ArgumentException("Client must be of type WinoImapClient.", nameof(client));
|
||||
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync) && winoImapClient.IsQResyncEnabled) return _qResyncSynchronizer;
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.CondStore)) return _condstoreSynchronizer;
|
||||
|
||||
return _uidBasedSynchronizer;
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 5162 QRESYNC IMAP Synchronization strategy.
|
||||
/// </summary>
|
||||
internal class QResyncSynchronizer : ImapSynchronizationStrategyBase
|
||||
{
|
||||
public QResyncSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<List<string>> HandleSynchronizationAsync(IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
if (client is not WinoImapClient winoClient)
|
||||
throw new ImapSynchronizerStrategyException("Client must be of type WinoImapClient.");
|
||||
|
||||
if (!client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
|
||||
throw new ImapSynchronizerStrategyException("Server does not support QRESYNC.");
|
||||
|
||||
if (!winoClient.IsQResyncEnabled)
|
||||
throw new ImapSynchronizerStrategyException("QRESYNC is not enabled for WinoImapClient.");
|
||||
|
||||
// Ready to implement QRESYNC synchronization.
|
||||
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
Folder = folder;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check the Uid validity first.
|
||||
// If they don't match, clear all the local data and perform full-resync.
|
||||
|
||||
bool isCacheValid = remoteFolder.UidValidity == folder.UidValidity;
|
||||
|
||||
if (!isCacheValid)
|
||||
{
|
||||
// TODO: Remove all local data.
|
||||
}
|
||||
|
||||
// Perform QRESYNC synchronization.
|
||||
var localHighestModSeq = (ulong)folder.HighestModeSeq;
|
||||
// HIGHESTMODSEQ must be a positive integer, 0 is illegal.
|
||||
// It's harmless to set it to 1, as RFC-compliant server without mod-seq would ignore this parameter.
|
||||
if (localHighestModSeq == 0) localHighestModSeq = 1;
|
||||
|
||||
remoteFolder.MessagesVanished += OnMessagesVanished;
|
||||
remoteFolder.MessageFlagsChanged += OnMessageFlagsChanged;
|
||||
|
||||
var allUids = await FolderService.GetKnownUidsForFolderAsync(folder.Id);
|
||||
var allUniqueIds = allUids.Select(a => new UniqueId(a)).ToList();
|
||||
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, allUniqueIds).ConfigureAwait(false);
|
||||
|
||||
var changedUids = await GetChangedUidsAsync(client, remoteFolder, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds = await HandleChangedUIdsAsync(synchronizer, remoteFolder, changedUids, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update the local folder with the new highest mod-seq and validity.
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
folder.UidValidity = remoteFolder.UidValidity;
|
||||
|
||||
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await FolderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
catch (FolderNotFoundException)
|
||||
{
|
||||
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (remoteFolder != null)
|
||||
{
|
||||
remoteFolder.MessagesVanished -= OnMessagesVanished;
|
||||
remoteFolder.MessageFlagsChanged -= OnMessageFlagsChanged;
|
||||
|
||||
if (remoteFolder.IsOpen)
|
||||
{
|
||||
await remoteFolder.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
internal override async Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var localHighestModSeq = (ulong)Folder.HighestModeSeq;
|
||||
if (localHighestModSeq == 0) localHighestModSeq = 1;
|
||||
return await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
/// <summary>
|
||||
/// Uid based IMAP Synchronization strategy.
|
||||
/// </summary>
|
||||
internal class UidBasedSynchronizer : ImapSynchronizationStrategyBase
|
||||
{
|
||||
public UidBasedSynchronizer(IFolderService folderService, Domain.Interfaces.IMailService mailService) : base(folderService, mailService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is not WinoImapClient winoClient)
|
||||
throw new ArgumentException("Client must be of type WinoImapClient.", nameof(client));
|
||||
|
||||
Folder = folder;
|
||||
|
||||
var downloadedMessageIds = new List<string>();
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await winoClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Fetch UIDs from the remote folder
|
||||
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
remoteUids = remoteUids.OrderByDescending(a => a.Id).Take((int)synchronizer.InitialMessageDownloadCountPerFolder).ToList();
|
||||
|
||||
await HandleChangedUIdsAsync(synchronizer, remoteFolder, remoteUids, cancellationToken).ConfigureAwait(false);
|
||||
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (FolderNotFoundException)
|
||||
{
|
||||
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (remoteFolder != null)
|
||||
{
|
||||
if (remoteFolder.IsOpen)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
internal override Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using MoreLinq;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration;
|
||||
using Wino.Services.Extensions;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
/// <summary>
|
||||
/// Unified IMAP synchronization strategy that automatically selects the best available method:
|
||||
/// 1. QRESYNC (RFC 5162) - Best: supports quick resync with vanished messages
|
||||
/// 2. CONDSTORE (RFC 4551) - Good: supports mod-seq based change tracking
|
||||
/// 3. UID-based delta - Fallback: tracks UIDNEXT/high-water UID without sequence-number persistence
|
||||
/// </summary>
|
||||
public class UnifiedImapSynchronizer
|
||||
{
|
||||
private static readonly TimeSpan UidReconcileInterval = TimeSpan.FromHours(12);
|
||||
|
||||
private readonly ILogger _logger = Log.ForContext<UnifiedImapSynchronizer>();
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
|
||||
|
||||
// Metadata-first synchronization flags: no full MIME body download.
|
||||
private readonly MessageSummaryItems _mailSynchronizationFlags =
|
||||
MessageSummaryItems.Flags |
|
||||
MessageSummaryItems.UniqueId |
|
||||
MessageSummaryItems.InternalDate |
|
||||
MessageSummaryItems.Envelope |
|
||||
MessageSummaryItems.Headers |
|
||||
MessageSummaryItems.PreviewText |
|
||||
MessageSummaryItems.GMailThreadId |
|
||||
MessageSummaryItems.References |
|
||||
MessageSummaryItems.ModSeq |
|
||||
MessageSummaryItems.BodyStructure;
|
||||
|
||||
public UnifiedImapSynchronizer(
|
||||
IFolderService folderService,
|
||||
IMailService mailService,
|
||||
IImapSynchronizerErrorHandlerFactory errorHandlerFactory)
|
||||
{
|
||||
_folderService = folderService;
|
||||
_mailService = mailService;
|
||||
_errorHandlerFactory = errorHandlerFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the best synchronization strategy based on server capabilities and known quirks.
|
||||
/// </summary>
|
||||
public ImapSyncStrategy DetermineSyncStrategy(IImapClient client, string serverHost)
|
||||
{
|
||||
var capabilities = client.Capabilities;
|
||||
var isQResyncEnabled = client is WinoImapClient winoClient && winoClient.IsQResyncEnabled;
|
||||
|
||||
return DetermineSyncStrategy(capabilities, isQResyncEnabled, serverHost);
|
||||
}
|
||||
|
||||
public ImapSyncStrategy DetermineSyncStrategy(ImapCapabilities capabilities, bool isQResyncEnabled, string serverHost = null)
|
||||
{
|
||||
var quirks = ImapServerQuirks.Resolve(serverHost);
|
||||
|
||||
if (!quirks.DisableQResync && capabilities.HasFlag(ImapCapabilities.QuickResync) && isQResyncEnabled)
|
||||
return ImapSyncStrategy.QResync;
|
||||
|
||||
if (!quirks.DisableCondstore && capabilities.HasFlag(ImapCapabilities.CondStore))
|
||||
return ImapSyncStrategy.Condstore;
|
||||
|
||||
return ImapSyncStrategy.UidBased;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main synchronization entry point. Automatically selects the best strategy.
|
||||
/// </summary>
|
||||
public async Task<FolderSyncResult> SynchronizeFolderAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
string serverHost,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var strategy = DetermineSyncStrategy(client, serverHost);
|
||||
_logger.Debug("Using {Strategy} sync strategy for folder {FolderName}", strategy, folder.FolderName);
|
||||
|
||||
var originalHighestModeSeq = folder.HighestModeSeq;
|
||||
var originalUidValidity = folder.UidValidity;
|
||||
var originalHighestKnownUid = folder.HighestKnownUid;
|
||||
var originalLastUidReconcileUtc = folder.LastUidReconcileUtc;
|
||||
|
||||
try
|
||||
{
|
||||
var downloadedIds = strategy switch
|
||||
{
|
||||
ImapSyncStrategy.QResync => await SynchronizeWithQResyncAsync(client, folder, synchronizer, cancellationToken).ConfigureAwait(false),
|
||||
ImapSyncStrategy.Condstore => await SynchronizeWithCondstoreAsync(client, folder, synchronizer, cancellationToken).ConfigureAwait(false),
|
||||
_ => await SynchronizeWithUidDeltaAsync(client, folder, synchronizer, cancellationToken).ConfigureAwait(false)
|
||||
};
|
||||
|
||||
bool highestModeSeqChanged = folder.HighestModeSeq != originalHighestModeSeq;
|
||||
bool requiresFullFolderUpdate =
|
||||
folder.UidValidity != originalUidValidity
|
||||
|| folder.HighestKnownUid != originalHighestKnownUid
|
||||
|| folder.LastUidReconcileUtc != originalLastUidReconcileUtc;
|
||||
|
||||
if (requiresFullFolderUpdate)
|
||||
{
|
||||
// Persist all sync-state fields in one write when any non-mod-seq token changed.
|
||||
await _folderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
else if (highestModeSeqChanged)
|
||||
{
|
||||
// Avoid full-folder write when only mod-seq changed.
|
||||
await _folderService.UpdateFolderHighestModeSeqAsync(folder.Id, folder.HighestModeSeq).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return FolderSyncResult.Successful(folder.Id, folder.FolderName, downloadedIds.Count);
|
||||
}
|
||||
catch (FolderNotFoundException)
|
||||
{
|
||||
_logger.Warning("Folder {FolderName} not found on server, deleting locally", folder.FolderName);
|
||||
await _folderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
return FolderSyncResult.Skipped(folder.Id, folder.FolderName, "Folder not found on server");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorContext = new SynchronizerErrorContext
|
||||
{
|
||||
ErrorMessage = ex.Message,
|
||||
Exception = ex,
|
||||
FolderId = folder.Id,
|
||||
FolderName = folder.FolderName,
|
||||
OperationType = "ImapFolderSync"
|
||||
};
|
||||
|
||||
_ = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||
|
||||
if (errorContext.CanContinueSync)
|
||||
{
|
||||
_logger.Warning(ex, "Folder {FolderName} sync failed with recoverable error", folder.FolderName);
|
||||
return FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext);
|
||||
}
|
||||
|
||||
_logger.Error(ex, "Folder {FolderName} sync failed with fatal error", folder.FolderName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata-only message download helper used by IMAP online search.
|
||||
/// </summary>
|
||||
public async Task<List<string>> DownloadMessagesByUidsAsync(
|
||||
IImapClient client,
|
||||
IMailFolder remoteFolder,
|
||||
MailItemFolder localFolder,
|
||||
IList<UniqueId> uids,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (uids == null || uids.Count == 0)
|
||||
return [];
|
||||
|
||||
if (!remoteFolder.IsOpen)
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
foreach (var batch in uids.Distinct().OrderBy(a => a.Id).Batch(50))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var summaryBatch = await remoteFolder
|
||||
.FetchAsync(new UniqueIdSet(batch.ToList(), SortOrder.Ascending), _mailSynchronizationFlags, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds.AddRange(await ProcessSummariesAsync(synchronizer, localFolder, summaryBatch, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
UpdateHighestKnownUid(localFolder, remoteFolder, uids.Select(a => a.Id));
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
#region Strategy Implementations
|
||||
|
||||
private async Task<List<string>> SynchronizeWithQResyncAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (client is not WinoImapClient)
|
||||
throw new InvalidOperationException("QRESYNC requires WinoImapClient.");
|
||||
|
||||
var downloadedMessageIds = new List<string>();
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
var vanishedUids = new List<UniqueId>();
|
||||
var changedFlags = new Dictionary<uint, MessageFlags>();
|
||||
|
||||
void OnMessagesVanished(object sender, MessagesVanishedEventArgs args)
|
||||
{
|
||||
lock (vanishedUids)
|
||||
{
|
||||
vanishedUids.AddRange(args.UniqueIds);
|
||||
}
|
||||
}
|
||||
|
||||
void OnMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs args)
|
||||
{
|
||||
if (args.UniqueId is not UniqueId uniqueId)
|
||||
return;
|
||||
|
||||
lock (changedFlags)
|
||||
{
|
||||
changedFlags[uniqueId.Id] = args.Flags;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Open once to validate UIDVALIDITY and reset local state if needed.
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureUidValidityStateAsync(folder, remoteFolder).ConfigureAwait(false);
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var knownUids = await _folderService.GetKnownUidsForFolderAsync(folder.Id).ConfigureAwait(false);
|
||||
var knownUidStructs = knownUids.Select(a => new UniqueId(a)).ToList();
|
||||
var localHighestModSeq = (ulong)Math.Max(folder.HighestModeSeq, 1);
|
||||
|
||||
remoteFolder.MessagesVanished += OnMessagesVanished;
|
||||
remoteFolder.MessageFlagsChanged += OnMessageFlagsChanged;
|
||||
|
||||
await remoteFolder
|
||||
.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, knownUidStructs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var changedUids = await remoteFolder
|
||||
.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
|
||||
await ApplyFlagChangesAsync(folder, changedFlags).ConfigureAwait(false);
|
||||
await ApplyDeletedUidsAsync(folder, vanishedUids).ConfigureAwait(false);
|
||||
|
||||
if (ShouldRunUidReconcile(folder))
|
||||
{
|
||||
await ReconcileDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (remoteFolder != null)
|
||||
{
|
||||
remoteFolder.MessagesVanished -= OnMessagesVanished;
|
||||
remoteFolder.MessageFlagsChanged -= OnMessageFlagsChanged;
|
||||
|
||||
if (remoteFolder.IsOpen && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
private async Task<List<string>> SynchronizeWithCondstoreAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await EnsureUidValidityStateAsync(folder, remoteFolder).ConfigureAwait(false);
|
||||
|
||||
var localHighestModSeq = (ulong)Math.Max(folder.HighestModeSeq, 1);
|
||||
bool isInitialSync = folder.HighestModeSeq == 0;
|
||||
|
||||
if (remoteFolder.HighestModSeq > localHighestModSeq || isInitialSync)
|
||||
{
|
||||
IList<UniqueId> changedUids;
|
||||
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Sort))
|
||||
{
|
||||
changedUids = await remoteFolder
|
||||
.SortAsync(SearchQuery.ChangedSince(localHighestModSeq), [OrderBy.ReverseDate], cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
changedUids = await remoteFolder
|
||||
.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (isInitialSync)
|
||||
{
|
||||
changedUids = changedUids
|
||||
.OrderByDescending(a => a.Id)
|
||||
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
}
|
||||
|
||||
if (ShouldRunUidReconcile(folder))
|
||||
{
|
||||
await ReconcileDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (remoteFolder?.IsOpen == true && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
private async Task<List<string>> SynchronizeWithUidDeltaAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await EnsureUidValidityStateAsync(folder, remoteFolder).ConfigureAwait(false);
|
||||
|
||||
if (folder.HighestKnownUid == 0)
|
||||
{
|
||||
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var initialUids = remoteUids
|
||||
.OrderByDescending(a => a.Id)
|
||||
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
|
||||
.ToList();
|
||||
|
||||
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, initialUids, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
UpdateHighestKnownUid(folder, remoteFolder, remoteUids.Select(a => a.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
var minUid = new UniqueId(folder.HighestKnownUid + 1);
|
||||
var deltaUids = await remoteFolder
|
||||
.SearchAsync(SearchQuery.Uids(new UniqueIdRange(minUid, UniqueId.MaxValue)), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, deltaUids, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
UpdateHighestKnownUid(folder, remoteFolder, deltaUids.Select(a => a.Id));
|
||||
}
|
||||
|
||||
await ReconcileUidBasedFlagChangesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (ShouldRunUidReconcile(folder))
|
||||
{
|
||||
await ReconcileDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (remoteFolder?.IsOpen == true && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared Helpers
|
||||
|
||||
private async Task EnsureUidValidityStateAsync(MailItemFolder folder, IMailFolder remoteFolder)
|
||||
{
|
||||
if (folder.UidValidity != 0 && remoteFolder.UidValidity != folder.UidValidity)
|
||||
{
|
||||
_logger.Warning("UIDVALIDITY changed for folder {FolderName}. Resetting local folder state.", folder.FolderName);
|
||||
|
||||
var existingMails = await _mailService.GetMailsByFolderIdAsync(folder.Id).ConfigureAwait(false);
|
||||
foreach (var mail in existingMails)
|
||||
{
|
||||
await _mailService.DeleteMailAsync(folder.MailAccountId, mail.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
folder.HighestKnownUid = 0;
|
||||
folder.HighestModeSeq = 0;
|
||||
folder.LastUidReconcileUtc = null;
|
||||
}
|
||||
|
||||
folder.UidValidity = remoteFolder.UidValidity;
|
||||
}
|
||||
|
||||
private async Task<List<string>> ProcessSummariesAsync(
|
||||
IImapSynchronizer synchronizer,
|
||||
MailItemFolder localFolder,
|
||||
IList<IMessageSummary> summaries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
if (summaries == null || summaries.Count == 0)
|
||||
return downloadedMessageIds;
|
||||
|
||||
var uniqueIds = summaries
|
||||
.Where(s => s.UniqueId != UniqueId.Invalid)
|
||||
.Select(s => s.UniqueId)
|
||||
.ToList();
|
||||
|
||||
if (uniqueIds.Count == 0)
|
||||
return downloadedMessageIds;
|
||||
|
||||
var existingMails = await _mailService.GetExistingMailsAsync(localFolder.Id, uniqueIds).ConfigureAwait(false);
|
||||
var existingByUid = existingMails
|
||||
.Select(m => (Uid: MailkitClientExtensions.ResolveUidStruct(m.Id), Mail: m))
|
||||
.ToDictionary(a => a.Uid.Id, a => a.Mail);
|
||||
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (summary.UniqueId == UniqueId.Invalid)
|
||||
continue;
|
||||
|
||||
if (existingByUid.TryGetValue(summary.UniqueId.Id, out var existingMail))
|
||||
{
|
||||
if (summary.Flags != null)
|
||||
{
|
||||
await UpdateMailFlagsAsync(existingMail, summary.Flags.Value).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage: null);
|
||||
var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, localFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mailPackages == null)
|
||||
continue;
|
||||
|
||||
foreach (var package in mailPackages)
|
||||
{
|
||||
if (package == null)
|
||||
continue;
|
||||
|
||||
var inserted = await _mailService.CreateMailAsync(localFolder.MailAccountId, package).ConfigureAwait(false);
|
||||
if (inserted)
|
||||
{
|
||||
downloadedMessageIds.Add(package.Copy.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
private async Task UpdateMailFlagsAsync(MailCopy mailCopy, MessageFlags flags)
|
||||
{
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(flags);
|
||||
|
||||
if (isFlagged != mailCopy.IsFlagged)
|
||||
{
|
||||
await _mailService.ChangeFlagStatusAsync(mailCopy.Id, isFlagged).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (isRead != mailCopy.IsRead)
|
||||
{
|
||||
await _mailService.ChangeReadStatusAsync(mailCopy.Id, isRead).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyDeletedUidsAsync(MailItemFolder folder, IList<UniqueId> uniqueIds)
|
||||
{
|
||||
if (uniqueIds == null || uniqueIds.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var uniqueId in uniqueIds.Distinct())
|
||||
{
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, uniqueId.Id);
|
||||
await _mailService.DeleteMailAsync(folder.MailAccountId, localMailCopyId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyFlagChangesAsync(MailItemFolder folder, IDictionary<uint, MessageFlags> changedFlags)
|
||||
{
|
||||
if (changedFlags == null || changedFlags.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var changed in changedFlags)
|
||||
{
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, changed.Key);
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(changed.Value);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(changed.Value);
|
||||
|
||||
await _mailService.ChangeReadStatusAsync(localMailCopyId, isRead).ConfigureAwait(false);
|
||||
await _mailService.ChangeFlagStatusAsync(localMailCopyId, isFlagged).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReconcileUidBasedFlagChangesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken)
|
||||
{
|
||||
var localMails = await _mailService.GetMailsByFolderIdAsync(localFolder.Id).ConfigureAwait(false);
|
||||
|
||||
if (localMails == null || localMails.Count == 0)
|
||||
return;
|
||||
|
||||
var localByUid = new Dictionary<uint, MailCopy>();
|
||||
var localUnreadUids = new HashSet<uint>();
|
||||
var localFlaggedUids = new HashSet<uint>();
|
||||
|
||||
foreach (var localMail in localMails)
|
||||
{
|
||||
if (localMail == null || string.IsNullOrEmpty(localMail.Id))
|
||||
continue;
|
||||
|
||||
uint uid;
|
||||
try
|
||||
{
|
||||
uid = MailkitClientExtensions.ResolveUid(localMail.Id);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
localByUid[uid] = localMail;
|
||||
|
||||
if (!localMail.IsRead)
|
||||
localUnreadUids.Add(uid);
|
||||
|
||||
if (localMail.IsFlagged)
|
||||
localFlaggedUids.Add(uid);
|
||||
}
|
||||
|
||||
if (localByUid.Count == 0)
|
||||
return;
|
||||
|
||||
var remoteUnreadUids = (await remoteFolder.SearchAsync(SearchQuery.NotSeen, cancellationToken).ConfigureAwait(false))
|
||||
.Select(a => a.Id)
|
||||
.ToHashSet();
|
||||
var remoteFlaggedUids = (await remoteFolder.SearchAsync(SearchQuery.Flagged, cancellationToken).ConfigureAwait(false))
|
||||
.Select(a => a.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var markReadCandidates = localUnreadUids.Except(remoteUnreadUids).ToList();
|
||||
var unflagCandidates = localFlaggedUids.Except(remoteFlaggedUids).ToList();
|
||||
|
||||
var existingMarkReadCandidates = await FilterExistingRemoteUidsAsync(remoteFolder, markReadCandidates, cancellationToken).ConfigureAwait(false);
|
||||
var existingUnflagCandidates = await FilterExistingRemoteUidsAsync(remoteFolder, unflagCandidates, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var uid in existingMarkReadCandidates)
|
||||
{
|
||||
if (!localByUid.TryGetValue(uid, out var localMail) || localMail.IsRead)
|
||||
continue;
|
||||
|
||||
await _mailService.ChangeReadStatusAsync(localMail.Id, true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var uid in remoteUnreadUids)
|
||||
{
|
||||
if (!localByUid.TryGetValue(uid, out var localMail) || !localMail.IsRead)
|
||||
continue;
|
||||
|
||||
await _mailService.ChangeReadStatusAsync(localMail.Id, false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var uid in existingUnflagCandidates)
|
||||
{
|
||||
if (!localByUid.TryGetValue(uid, out var localMail) || !localMail.IsFlagged)
|
||||
continue;
|
||||
|
||||
await _mailService.ChangeFlagStatusAsync(localMail.Id, false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var uid in remoteFlaggedUids)
|
||||
{
|
||||
if (!localByUid.TryGetValue(uid, out var localMail) || localMail.IsFlagged)
|
||||
continue;
|
||||
|
||||
await _mailService.ChangeFlagStatusAsync(localMail.Id, true).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<HashSet<uint>> FilterExistingRemoteUidsAsync(IMailFolder remoteFolder, IEnumerable<uint> candidateUids, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = new HashSet<uint>();
|
||||
var uidList = candidateUids?.Distinct().ToList();
|
||||
|
||||
if (uidList == null || uidList.Count == 0)
|
||||
return existing;
|
||||
|
||||
foreach (var batch in uidList.Batch(200))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var batchUids = batch.Select(a => new UniqueId(a)).ToList();
|
||||
var existingBatch = await remoteFolder
|
||||
.SearchAsync(SearchQuery.Uids(new UniqueIdSet(batchUids, SortOrder.Ascending)), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var existingUid in existingBatch)
|
||||
{
|
||||
existing.Add(existingUid.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
private bool ShouldRunUidReconcile(MailItemFolder folder)
|
||||
{
|
||||
return ShouldRunUidReconcile(folder.LastUidReconcileUtc, DateTime.UtcNow, UidReconcileInterval);
|
||||
}
|
||||
|
||||
private async Task ReconcileDeletedMessagesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken)
|
||||
{
|
||||
var allLocalUids = (await _folderService.GetKnownUidsForFolderAsync(localFolder.Id).ConfigureAwait(false))
|
||||
.Select(a => new UniqueId(a))
|
||||
.ToList();
|
||||
|
||||
if (allLocalUids.Count == 0)
|
||||
{
|
||||
localFolder.LastUidReconcileUtc = DateTime.UtcNow;
|
||||
return;
|
||||
}
|
||||
|
||||
var remoteAllUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
var deletedUids = allLocalUids.Except(remoteAllUids).ToList();
|
||||
|
||||
await ApplyDeletedUidsAsync(localFolder, deletedUids).ConfigureAwait(false);
|
||||
localFolder.LastUidReconcileUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static void UpdateHighestKnownUid(MailItemFolder folder, IMailFolder remoteFolder, IEnumerable<uint> observedUids)
|
||||
{
|
||||
folder.HighestKnownUid = CalculateHighestKnownUid(folder.HighestKnownUid, remoteFolder?.UidNext, observedUids);
|
||||
}
|
||||
|
||||
public static bool ShouldRunUidReconcile(DateTime? lastUidReconcileUtc, DateTime utcNow, TimeSpan reconcileInterval)
|
||||
{
|
||||
if (!lastUidReconcileUtc.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return utcNow - lastUidReconcileUtc.Value >= reconcileInterval;
|
||||
}
|
||||
|
||||
public static uint CalculateHighestKnownUid(uint currentHighestKnownUid, UniqueId? uidNext, IEnumerable<uint> observedUids)
|
||||
{
|
||||
uint observedMax = 0;
|
||||
|
||||
if (observedUids != null)
|
||||
{
|
||||
foreach (var uid in observedUids)
|
||||
{
|
||||
if (uid > observedMax)
|
||||
{
|
||||
observedMax = uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint uidNextBased = 0;
|
||||
if (uidNext.HasValue)
|
||||
{
|
||||
uidNextBased = uidNext.Value.Id > 0 ? uidNext.Value.Id - 1 : 0;
|
||||
}
|
||||
|
||||
return Math.Max(currentHighestKnownUid, Math.Max(observedMax, uidNextBased));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IMAP synchronization strategy enumeration.
|
||||
/// </summary>
|
||||
public enum ImapSyncStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// RFC 5162 Quick Resync - supports vanished messages and efficient delta sync.
|
||||
/// </summary>
|
||||
QResync,
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4551 Conditional Store - supports mod-seq based change tracking.
|
||||
/// </summary>
|
||||
Condstore,
|
||||
|
||||
/// <summary>
|
||||
/// UID-based delta synchronization fallback.
|
||||
/// </summary>
|
||||
UidBased
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
using Wino.Core.Requests.Calendar;
|
||||
using Wino.Core.Requests.Folder;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Messaging.UI;
|
||||
@@ -32,7 +33,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
|
||||
protected ILogger Logger = Log.ForContext<WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEventType>>();
|
||||
|
||||
protected WinoSynchronizer(MailAccount account) : base(account) { }
|
||||
protected WinoSynchronizer(MailAccount account, IMessenger messenger) : base(account, messenger) { }
|
||||
|
||||
/// <summary>
|
||||
/// How many items per single HTTP call can be modified.
|
||||
@@ -41,15 +42,19 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
|
||||
/// <summary>
|
||||
/// How many items must be downloaded per folder when the folder is first synchronized.
|
||||
/// Only metadata is downloaded during sync - MIME content is fetched on-demand when user reads mail.
|
||||
/// </summary>
|
||||
public abstract uint InitialMessageDownloadCountPerFolder { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Wino Mail Item package out of native message type with full Mime.
|
||||
/// Creates a new Wino Mail Item package out of native message type with metadata only.
|
||||
/// NO MIME content is downloaded during synchronization - only headers and essential metadata.
|
||||
/// MIME will be downloaded on-demand when user explicitly reads the message.
|
||||
/// </summary>
|
||||
/// <param name="message">Native message type for the synchronizer.</param>
|
||||
/// <param name="assignedFolder">Folder to assign the mail to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Package that encapsulates downloaded Mime and additional information for adding new mail.</returns>
|
||||
/// <returns>Package with MailCopy metadata. MimeMessage will be null during sync.</returns>
|
||||
public abstract Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -58,6 +63,38 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
/// </summary>
|
||||
protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Queues all mail ids for initial synchronization for a specific folder.
|
||||
/// Only overridden by synchronizers that support the new queue-based sync.
|
||||
/// </summary>
|
||||
/// <param name="folder">Folder to queue mail ids for</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Task</returns>
|
||||
protected virtual Task QueueMailIdsForInitialSyncAsync(MailItemFolder folder, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads mail items from the queue in batches.
|
||||
/// Only overridden by synchronizers that support the new queue-based sync.
|
||||
/// </summary>
|
||||
/// <param name="folder">Folder to download mails for</param>
|
||||
/// <param name="batchSize">Number of items to download in each batch</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of downloaded mail ids</returns>
|
||||
protected virtual Task<List<string>> DownloadMailsFromQueueAsync(MailItemFolder folder, int batchSize, CancellationToken cancellationToken = default) => Task.FromResult(new List<string>());
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MailCopy object with minimal properties from the native message type.
|
||||
/// This is used during synchronization to create mail entries WITHOUT downloading MIME content.
|
||||
/// Only metadata (headers, labels, flags) is extracted from the native message format.
|
||||
/// MIME content will be downloaded later on-demand when user reads the message.
|
||||
/// Only overridden by synchronizers that support metadata-only synchronization.
|
||||
/// </summary>
|
||||
/// <param name="message">Native message type</param>
|
||||
/// <param name="assignedFolder">Folder this message belongs to</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>MailCopy with minimal properties populated from metadata</returns>
|
||||
protected virtual Task<MailCopy> CreateMinimalMailCopyAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) => Task.FromResult<MailCopy>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Internally synchronizes the account's mails with the given options.
|
||||
/// Not exposed and overriden for each synchronizer.
|
||||
@@ -166,6 +203,12 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
case FolderSynchronizerOperation.MarkFolderRead:
|
||||
nativeRequests.AddRange(MarkFolderAsRead(group.ElementAt(0) as MarkFolderAsReadRequest));
|
||||
break;
|
||||
case FolderSynchronizerOperation.DeleteFolder:
|
||||
nativeRequests.AddRange(DeleteFolder(group.ElementAt(0) as DeleteFolderRequest));
|
||||
break;
|
||||
case FolderSynchronizerOperation.CreateSubFolder:
|
||||
nativeRequests.AddRange(CreateSubFolder(group.ElementAt(0) as CreateSubFolderRequest));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -176,7 +219,16 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
|
||||
Console.WriteLine($"Prepared {nativeRequests.Count()} native requests");
|
||||
|
||||
await ExecuteNativeRequestsAsync(nativeRequests, activeSynchronizationCancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ExecuteNativeRequestsAsync(nativeRequests, activeSynchronizationCancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UntrackProcessedRequests(requestCopies);
|
||||
}
|
||||
|
||||
Messenger.Send(new SynchronizationActionsCompleted(Account.Id));
|
||||
|
||||
PublishUnreadItemChanges();
|
||||
|
||||
@@ -205,7 +257,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
|
||||
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
|
||||
|
||||
PublishSynchronizationProgress(1);
|
||||
// Set indeterminate progress for initial state
|
||||
UpdateSyncProgress(0, 0, "Synchronizing...");
|
||||
|
||||
State = AccountSynchronizerState.Synchronizing;
|
||||
|
||||
@@ -226,7 +279,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
{
|
||||
Log.Error(ex, "Failed to update profile information for {Name}", Account.Name);
|
||||
|
||||
return MailSynchronizationResult.Failed;
|
||||
return MailSynchronizationResult.Failed(ex);
|
||||
}
|
||||
|
||||
return MailSynchronizationResult.Completed(newProfileInformation);
|
||||
@@ -247,7 +300,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
{
|
||||
Log.Error(ex, "Failed to update aliases for {Name}", Account.Name);
|
||||
|
||||
return MailSynchronizationResult.Failed;
|
||||
return MailSynchronizationResult.Failed(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +339,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
PendingSynchronizationRequest.Remove(pendingRequest.Key);
|
||||
}
|
||||
|
||||
// Reset account progress to hide the progress.
|
||||
PublishSynchronizationProgress(0);
|
||||
// Reset synchronization progress
|
||||
ResetSyncProgress();
|
||||
|
||||
State = AccountSynchronizerState.Idle;
|
||||
synchronizationSemaphore.Release();
|
||||
@@ -300,10 +353,106 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
/// <param name="options">Synchronization options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Synchronization result that contains summary of the sync.</returns>
|
||||
public Task<CalendarSynchronizationResult> SynchronizeCalendarEventsAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
public async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Execute requests for calendar events.
|
||||
return SynchronizeCalendarEventsInternalAsync(options, cancellationToken);
|
||||
bool shouldExecuteRequests = changeRequestQueue.Any(r => r is ICalendarActionRequest);
|
||||
bool shouldDelayExecution = false;
|
||||
int maxExecutionDelay = 0;
|
||||
|
||||
if (shouldExecuteRequests)
|
||||
{
|
||||
State = AccountSynchronizerState.ExecutingRequests;
|
||||
|
||||
List<IRequestBundle<TBaseRequest>> nativeRequests = new();
|
||||
List<IRequestBase> requestCopies = new(changeRequestQueue.Where(r => r is ICalendarActionRequest));
|
||||
|
||||
var keys = requestCopies.GroupBy(a => a.GroupingKey());
|
||||
|
||||
foreach (var group in keys)
|
||||
{
|
||||
var key = group.Key;
|
||||
|
||||
if (key is CalendarSynchronizerOperation calendarSynchronizerOperation)
|
||||
{
|
||||
switch (calendarSynchronizerOperation)
|
||||
{
|
||||
case CalendarSynchronizerOperation.CreateEvent:
|
||||
nativeRequests.AddRange(group
|
||||
.OfType<CreateCalendarEventRequest>()
|
||||
.SelectMany(CreateCalendarEvent));
|
||||
break;
|
||||
case CalendarSynchronizerOperation.AcceptEvent:
|
||||
nativeRequests.AddRange(group
|
||||
.OfType<AcceptEventRequest>()
|
||||
.SelectMany(AcceptEvent));
|
||||
break;
|
||||
case CalendarSynchronizerOperation.DeclineEvent:
|
||||
if (Account.ProviderType == MailProviderType.Outlook)
|
||||
{
|
||||
nativeRequests.AddRange(group
|
||||
.OfType<OutlookDeclineEventRequest>()
|
||||
.SelectMany(OutlookDeclineEvent));
|
||||
}
|
||||
else
|
||||
{
|
||||
nativeRequests.AddRange(group
|
||||
.OfType<DeclineEventRequest>()
|
||||
.SelectMany(DeclineEvent));
|
||||
}
|
||||
break;
|
||||
case CalendarSynchronizerOperation.TentativeEvent:
|
||||
nativeRequests.AddRange(group
|
||||
.OfType<TentativeEventRequest>()
|
||||
.SelectMany(TentativeEvent));
|
||||
break;
|
||||
case CalendarSynchronizerOperation.UpdateEvent:
|
||||
nativeRequests.AddRange(group
|
||||
.OfType<UpdateCalendarEventRequest>()
|
||||
.SelectMany(UpdateCalendarEvent));
|
||||
break;
|
||||
case CalendarSynchronizerOperation.DeleteEvent:
|
||||
nativeRequests.AddRange(group
|
||||
.OfType<DeleteCalendarEventRequest>()
|
||||
.SelectMany(DeleteCalendarEvent));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove processed calendar requests from queue
|
||||
changeRequestQueue.RemoveAll(r => r is ICalendarActionRequest);
|
||||
|
||||
Console.WriteLine($"Prepared {nativeRequests.Count()} native calendar requests");
|
||||
|
||||
try
|
||||
{
|
||||
await ExecuteNativeRequestsAsync(nativeRequests, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UntrackProcessedRequests(requestCopies);
|
||||
}
|
||||
|
||||
Messenger.Send(new SynchronizationActionsCompleted(Account.Id));
|
||||
|
||||
// Let servers to finish their job. Sometimes the servers don't respond immediately.
|
||||
shouldDelayExecution = requestCopies.Any(a => a.ResynchronizationDelay > 0);
|
||||
|
||||
if (shouldDelayExecution)
|
||||
{
|
||||
maxExecutionDelay = requestCopies.Aggregate(0, (max, next) => Math.Max(max, next.ResynchronizationDelay));
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDelayExecution)
|
||||
{
|
||||
await Task.Delay(maxExecutionDelay, cancellationToken);
|
||||
}
|
||||
|
||||
// Execute the actual synchronization
|
||||
return await SynchronizeCalendarEventsInternalAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -313,13 +462,6 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
private void PublishUnreadItemChanges()
|
||||
=> WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(Account.Id));
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message to the shell to update the synchronization progress.
|
||||
/// </summary>
|
||||
/// <param name="progress">Percentage of the progress.</param>
|
||||
public void PublishSynchronizationProgress(double progress)
|
||||
=> WeakReferenceMessenger.Default.Send(new AccountSynchronizationProgressUpdatedMessage(Account.Id, progress));
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to find out the best possible synchronization options after the batch request execution.
|
||||
/// </summary>
|
||||
@@ -400,11 +542,20 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
public virtual List<IRequestBundle<TBaseRequest>> RenameFolder(RenameFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> EmptyFolder(EmptyFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> DeleteFolder(DeleteFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> CreateSubFolder(CreateSubFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Calendar Operations
|
||||
|
||||
public virtual List<IRequestBundle<TBaseRequest>> CreateCalendarEvent(CreateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> UpdateCalendarEvent(UpdateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> DeleteCalendarEvent(DeleteCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> AcceptEvent(AcceptEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> DeclineEvent(DeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> OutlookDeclineEvent(OutlookDeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> TentativeEvent(TentativeEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -415,7 +566,20 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
/// <param name="mailItem">Mail item that its mime file does not exist on the disk.</param>
|
||||
/// <param name="transferProgress">Optional download progress for IMAP synchronizer.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public virtual Task DownloadMissingMimeMessageAsync(IMailItem mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual Task DownloadMissingMimeMessageAsync(MailCopy mailItem, ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a calendar attachment from the provider.
|
||||
/// </summary>
|
||||
/// <param name="calendarItem">Calendar item the attachment belongs to.</param>
|
||||
/// <param name="attachment">Attachment metadata to download.</param>
|
||||
/// <param name="localFilePath">Local file path to save the attachment to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public virtual Task DownloadCalendarAttachmentAsync(
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem,
|
||||
Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment,
|
||||
string localFilePath,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
|
||||
/// <summary>
|
||||
/// Performs an online search for the given query text in the given folders.
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<RootNamespace>Wino.Core</RootNamespace>
|
||||
<Platforms>x86;x64;arm64</Platforms>
|
||||
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
||||
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
|
||||
<IsTrimmable>true</IsTrimmable>
|
||||
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Diagnostics" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Google.Apis.Calendar.v3" />
|
||||
<PackageReference Include="Google.Apis.Drive.v3" />
|
||||
<PackageReference Include="Google.Apis.Gmail.v1" />
|
||||
<PackageReference Include="Google.Apis.PeopleService.v1" />
|
||||
<PackageReference Include="HtmlAgilityPack" />
|
||||
@@ -28,8 +32,6 @@
|
||||
<PackageReference Include="NodaTime" />
|
||||
<PackageReference Include="Sentry.Serilog" />
|
||||
<PackageReference Include="SkiaSharp" />
|
||||
<PackageReference Include="SqlKata" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -38,4 +40,8 @@
|
||||
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
|
||||
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Domain\Models\Errors\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user