Merged feature/vNext. Initial commit for Wino Mail 2.0

This commit is contained in:
Burak Kaan Köse
2026-04-05 16:30:26 +02:00
1513 changed files with 93788 additions and 26896 deletions
+31 -5
View File
@@ -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
};
}
}
+147
View File
@@ -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
+39
View File
@@ -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);
}
}
}
+2 -7
View File
@@ -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;
+81 -30
View File
@@ -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;
}
-24
View File
@@ -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; }
}
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Wino.Core.Tests")]
+3 -5
View File
@@ -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];
+17 -3
View File
@@ -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.
}
}
+17 -3
View File
@@ -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));
}
}
+667 -27
View File
@@ -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
}
}
+19 -58
View File
@@ -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);
}
+140
View File
@@ -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;
+25 -13
View File
@@ -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,
};
}
}
+134 -28
View File
@@ -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;
}
}
+26 -12
View File
@@ -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;
+133 -4
View File
@@ -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
+184 -20
View File
@@ -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.
+10 -4
View File
@@ -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>