Calendar invitations for Mail part of the app.

This commit is contained in:
Burak Kaan Köse
2026-01-05 00:21:07 +01:00
parent 0b0f6b8d8e
commit 3d07328f47
24 changed files with 679 additions and 66 deletions
+2 -1
View File
@@ -98,7 +98,8 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
catch (MsalUiRequiredException) catch (MsalUiRequiredException)
{ {
// Somehow MSAL is not able to refresh the token silently. // Somehow MSAL is not able to refresh the token silently.
// Force interactive login. // Force interactive login which will include calendar scopes.
// The calling code should update account.IsCalendarAccessGranted = true after successful authentication.
return await GenerateTokenInformationAsync(account); return await GenerateTokenInformationAsync(account);
} }
@@ -27,17 +27,13 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
IRecipient<VisibleDateRangeChangedMessage>, IRecipient<VisibleDateRangeChangedMessage>,
IRecipient<CalendarEnableStatusChangedMessage>, IRecipient<CalendarEnableStatusChangedMessage>,
IRecipient<NavigateManageAccountsRequested>, IRecipient<NavigateManageAccountsRequested>,
IRecipient<CalendarDisplayTypeChangedMessage>, IRecipient<CalendarDisplayTypeChangedMessage>
IRecipient<DetailsPageStateChangedMessage>
{ {
public IPreferencesService PreferencesService { get; } public IPreferencesService PreferencesService { get; }
public IStatePersistanceService StatePersistenceService { get; } public IStatePersistanceService StatePersistenceService { get; }
public IAccountCalendarStateService AccountCalendarStateService { get; } public IAccountCalendarStateService AccountCalendarStateService { get; }
public INavigationService NavigationService { get; } public INavigationService NavigationService { get; }
[ObservableProperty]
private bool _isEventDetailsPageActive;
[ObservableProperty] [ObservableProperty]
private int _selectedMenuItemIndex = -1; private int _selectedMenuItemIndex = -1;
@@ -303,7 +299,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
Messenger.Register<CalendarEnableStatusChangedMessage>(this); Messenger.Register<CalendarEnableStatusChangedMessage>(this);
Messenger.Register<NavigateManageAccountsRequested>(this); Messenger.Register<NavigateManageAccountsRequested>(this);
Messenger.Register<CalendarDisplayTypeChangedMessage>(this); Messenger.Register<CalendarDisplayTypeChangedMessage>(this);
Messenger.Register<DetailsPageStateChangedMessage>(this);
} }
protected override void UnregisterRecipients() protected override void UnregisterRecipients()
@@ -314,7 +309,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
Messenger.Unregister<CalendarEnableStatusChangedMessage>(this); Messenger.Unregister<CalendarEnableStatusChangedMessage>(this);
Messenger.Unregister<NavigateManageAccountsRequested>(this); Messenger.Unregister<NavigateManageAccountsRequested>(this);
Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this); Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this);
Messenger.Unregister<DetailsPageStateChangedMessage>(this);
} }
public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange; public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange;
@@ -369,16 +363,4 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItemIndex = 1; public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItemIndex = 1;
public void Receive(CalendarDisplayTypeChangedMessage message) => OnPropertyChanged(nameof(IsVerticalCalendar)); public void Receive(CalendarDisplayTypeChangedMessage message) => OnPropertyChanged(nameof(IsVerticalCalendar));
public async void Receive(DetailsPageStateChangedMessage message)
{
await ExecuteUIThread(() =>
{
IsEventDetailsPageActive = message.IsActivated;
// TODO: This is for Wino Mail. Generalize this later on.
StatePersistenceService.IsReaderNarrowed = message.IsActivated;
StatePersistenceService.IsReadingMail = message.IsActivated;
});
}
} }
@@ -173,14 +173,29 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
{ {
base.OnNavigatedTo(mode, parameters); base.OnNavigatedTo(mode, parameters);
Messenger.Send(new DetailsPageStateChangedMessage(true));
if (parameters == null || parameters is not CalendarItemTarget args) if (parameters == null || parameters is not CalendarItemTarget args)
return; return;
await LoadCalendarItemTargetAsync(args); await LoadCalendarItemTargetAsync(args);
} }
protected override async void OnCalendarItemUpdated(CalendarItem calendarItem)
{
base.OnCalendarItemUpdated(calendarItem);
// If the current event was updated, reload it
if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id)
{
// Refresh the current event data by reloading from service
var refreshedEvent = await _calendarService.GetCalendarItemAsync(calendarItem.Id);
if (refreshedEvent != null)
{
CurrentEvent = new CalendarItemViewModel(refreshedEvent);
await LoadAttendeesAsync(refreshedEvent.EventTrackingId, refreshedEvent);
}
}
}
protected override void OnCalendarItemDeleted(CalendarItem calendarItem) protected override void OnCalendarItemDeleted(CalendarItem calendarItem)
{ {
base.OnCalendarItemDeleted(calendarItem); base.OnCalendarItemDeleted(calendarItem);
@@ -205,6 +220,9 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
await LoadAttendeesAsync(currentEventItem.EventTrackingId, currentEventItem); await LoadAttendeesAsync(currentEventItem.EventTrackingId, currentEventItem);
// Initialize SelectedShowAsOption based on current event's ShowAs
SelectedShowAsOption = ShowAsOptions.FirstOrDefault(o => o.ShowAs == currentEventItem.ShowAs) ?? ShowAsOptions[2];
// Load reminders for this calendar item // Load reminders for this calendar item
Reminders = await _calendarService.GetRemindersAsync(currentEventItem.EventTrackingId); Reminders = await _calendarService.GetRemindersAsync(currentEventItem.EventTrackingId);
InitializeReminderOptions(); InitializeReminderOptions();
@@ -221,15 +239,21 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
private async Task LoadAttendeesAsync(Guid eventTrackingId, CalendarItem calendarItem) private async Task LoadAttendeesAsync(Guid eventTrackingId, CalendarItem calendarItem)
{ {
CurrentEvent.Attendees.Clear(); CurrentEvent.Attendees.Clear();
var attendees = await _calendarService.GetAttendeesAsync(eventTrackingId); var attendees = await _calendarService.GetAttendeesAsync(eventTrackingId);
// Check if organizer is in the attendees list // Separate organizer from other attendees to ensure organizer is always first
var hasOrganizerInList = attendees.Any(a => a.IsOrganizer); var organizer = attendees.FirstOrDefault(a => a.IsOrganizer);
var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList();
// If the user is the organizer but not in attendees list, add them // If the organizer is in the list, add them first
if (!hasOrganizerInList && !string.IsNullOrEmpty(calendarItem.OrganizerEmail)) if (organizer != null)
{ {
CurrentEvent.Attendees.Add(organizer);
}
else if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail))
{
// If the organizer is not in the attendees list, create and add them first
var organizerAttendee = new CalendarEventAttendee var organizerAttendee = new CalendarEventAttendee
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -242,7 +266,8 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
CurrentEvent.Attendees.Add(organizerAttendee); CurrentEvent.Attendees.Add(organizerAttendee);
} }
foreach (var item in attendees) // Add all other attendees after the organizer
foreach (var item in nonOrganizerAttendees)
{ {
CurrentEvent.Attendees.Add(item); CurrentEvent.Attendees.Add(item);
} }
@@ -321,6 +346,10 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
try try
{ {
// Capture original state BEFORE making any changes for potential revert
var originalItem = await _calendarService.GetCalendarItemAsync(CurrentEvent.CalendarItem.Id);
var originalAttendees = await _calendarService.GetAttendeesAsync(CurrentEvent.CalendarItem.EventTrackingId);
// Get selected reminder options // Get selected reminder options
var selectedOptions = ReminderOptions.Where(o => o.IsSelected).ToList(); var selectedOptions = ReminderOptions.Where(o => o.IsSelected).ToList();
@@ -344,12 +373,35 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
await _calendarService.SaveRemindersAsync(CurrentEvent.CalendarItem.EventTrackingId, newReminders); await _calendarService.SaveRemindersAsync(CurrentEvent.CalendarItem.EventTrackingId, newReminders);
Reminders = newReminders; Reminders = newReminders;
// Update ShowAs if changed
if (SelectedShowAsOption != null)
{
CurrentEvent.CalendarItem.ShowAs = SelectedShowAsOption.ShowAs;
}
// Update the calendar item and attendees in database
await _calendarService.UpdateCalendarItemAsync(CurrentEvent.CalendarItem, CurrentEvent.Attendees.ToList());
// Queue the update request to synchronizer with original state for revert capability
var preparationRequest = new CalendarOperationPreparationRequest(
CalendarSynchronizerOperation.UpdateEvent,
CurrentEvent.CalendarItem,
CurrentEvent.Attendees.ToList(),
ResponseMessage: null,
OriginalItem: originalItem,
OriginalAttendees: originalAttendees);
await _winoRequestDelegator.ExecuteAsync(preparationRequest);
_navigationService.GoBack(); _navigationService.GoBack();
// TODO: Implement saving other event details
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.WriteLine($"Error saving event: {ex.Message}"); Debug.WriteLine($"Error saving event: {ex.Message}");
_dialogService.InfoBarMessage(
Translator.Info_AttachmentSaveFailedTitle,
ex.Message,
InfoBarMessageType.Error);
} }
} }
@@ -140,6 +140,12 @@ public class CalendarItem : ICalendarItem
public string HtmlLink { get; set; } public string HtmlLink { get; set; }
public CalendarItemStatus Status { get; set; } public CalendarItemStatus Status { get; set; }
public CalendarItemVisibility Visibility { get; set; } public CalendarItemVisibility Visibility { get; set; }
/// <summary>
/// Indicates how the event should be shown in the calendar (Free, Busy, Tentative, etc.).
/// </summary>
public CalendarItemShowAs ShowAs { get; set; } = CalendarItemShowAs.Busy;
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }
public Guid CalendarId { get; set; } public Guid CalendarId { get; set; }
@@ -103,6 +103,11 @@ public class MailCopy
/// </summary> /// </summary>
public bool HasAttachments { get; set; } public bool HasAttachments { get; set; }
/// <summary>
/// Type of mail item (regular mail, calendar invitation, calendar response, etc.).
/// </summary>
public MailItemType ItemType { get; set; } = MailItemType.Mail;
/// <summary> /// <summary>
/// Assigned draft id. /// Assigned draft id.
/// </summary> /// </summary>
@@ -78,6 +78,14 @@ public class MailAccount
/// </summary> /// </summary>
public SpecialImapProvider SpecialImapProvider { get; set; } public SpecialImapProvider SpecialImapProvider { get; set; }
/// <summary>
/// Gets or sets whether calendar access is granted for this account.
/// When false, synchronizers will not process EventMessages or calendar invitations.
/// Default is false for existing accounts to prevent scope issues.
/// New accounts created after this feature will have this set to true.
/// </summary>
public bool IsCalendarAccessGranted { get; set; }
/// <summary> /// <summary>
/// Contains the merged inbox this account belongs to. /// Contains the merged inbox this account belongs to.
/// Ignored for all SQLite operations. /// Ignored for all SQLite operations.
+27
View File
@@ -0,0 +1,27 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Represents the type of mail item.
/// </summary>
public enum MailItemType
{
/// <summary>
/// Regular mail message.
/// </summary>
Mail = 0,
/// <summary>
/// Calendar invitation (meeting request).
/// </summary>
CalendarInvitation = 1,
/// <summary>
/// Calendar response (meeting accepted, tentatively accepted, or declined).
/// </summary>
CalendarResponse = 2,
/// <summary>
/// Calendar cancellation (meeting cancelled).
/// </summary>
CalendarCancellation = 3
}
@@ -11,4 +11,12 @@ namespace Wino.Core.Domain.Models.Calendar;
/// <param name="CalendarItem">Calendar item to operate on.</param> /// <param name="CalendarItem">Calendar item to operate on.</param>
/// <param name="Attendees">List of attendees for the calendar event.</param> /// <param name="Attendees">List of attendees for the calendar event.</param>
/// <param name="ResponseMessage">Optional message to include with event responses (Accept, Decline, Tentative).</param> /// <param name="ResponseMessage">Optional message to include with event responses (Accept, Decline, Tentative).</param>
public record CalendarOperationPreparationRequest(CalendarSynchronizerOperation Operation, CalendarItem CalendarItem, List<CalendarEventAttendee> Attendees, string ResponseMessage = null); /// <param name="OriginalItem">Original calendar item state before update (for revert capability).</param>
/// <param name="OriginalAttendees">Original attendees list before update (for revert capability).</param>
public record CalendarOperationPreparationRequest(
CalendarSynchronizerOperation Operation,
CalendarItem CalendarItem,
List<CalendarEventAttendee> Attendees,
string ResponseMessage = null,
CalendarItem OriginalItem = null,
List<CalendarEventAttendee> OriginalAttendees = null);
@@ -60,7 +60,8 @@ public static class OutlookIntegratorExtensions
FromName = outlookMessage.From?.EmailAddress?.Name, FromName = outlookMessage.From?.EmailAddress?.Name,
FromAddress = outlookMessage.From?.EmailAddress?.Address, FromAddress = outlookMessage.From?.EmailAddress?.Address,
Subject = outlookMessage.Subject, Subject = outlookMessage.Subject,
FileId = Guid.NewGuid() FileId = Guid.NewGuid(),
ItemType = MailItemType.Mail // ItemType will be set by caller if calendar access is granted
}; };
if (mailCopy.IsDraft) if (mailCopy.IsDraft)
@@ -69,6 +70,51 @@ public static class OutlookIntegratorExtensions
return mailCopy; return mailCopy;
} }
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) public static Message AsOutlookMessage(this MimeMessage mime, bool includeInternetHeaders, string conversationId = null)
{ {
var fromAddress = GetRecipients(mime.From).ElementAt(0); var fromAddress = GetRecipients(mime.From).ElementAt(0);
@@ -286,7 +332,7 @@ public static class OutlookIntegratorExtensions
public static CalendarEventAttendee CreateAttendee(this Attendee attendee, Guid calendarItemId, string organizerEmail = null) public static CalendarEventAttendee CreateAttendee(this Attendee attendee, Guid calendarItemId, string organizerEmail = null)
{ {
// Check if this attendee is the organizer by comparing email addresses // Check if this attendee is the organizer by comparing email addresses
bool isOrganizer = !string.IsNullOrEmpty(organizerEmail) && bool isOrganizer = !string.IsNullOrEmpty(organizerEmail) &&
!string.IsNullOrEmpty(attendee?.EmailAddress?.Address) && !string.IsNullOrEmpty(attendee?.EmailAddress?.Address) &&
string.Equals(attendee.EmailAddress.Address, organizerEmail, StringComparison.OrdinalIgnoreCase); string.Equals(attendee.EmailAddress.Address, organizerEmail, StringComparison.OrdinalIgnoreCase);
@@ -111,6 +111,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
Title = string.IsNullOrEmpty(calendarEvent.Summary) ? parentRecurringEvent.Title : calendarEvent.Summary, Title = string.IsNullOrEmpty(calendarEvent.Summary) ? parentRecurringEvent.Title : calendarEvent.Summary,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
Visibility = string.IsNullOrEmpty(calendarEvent.Visibility) ? parentRecurringEvent.Visibility : GetVisibility(calendarEvent.Visibility), 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, HtmlLink = string.IsNullOrEmpty(calendarEvent.HtmlLink) ? parentRecurringEvent.HtmlLink : calendarEvent.HtmlLink,
RemoteEventId = calendarEvent.Id, RemoteEventId = calendarEvent.Id,
IsLocked = calendarEvent.Locked.GetValueOrDefault(), IsLocked = calendarEvent.Locked.GetValueOrDefault(),
@@ -148,6 +149,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
Title = calendarEvent.Summary, Title = calendarEvent.Summary,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
Visibility = GetVisibility(calendarEvent.Visibility), Visibility = GetVisibility(calendarEvent.Visibility),
ShowAs = GetShowAs(calendarEvent.Transparency),
HtmlLink = calendarEvent.HtmlLink, HtmlLink = calendarEvent.HtmlLink,
RemoteEventId = calendarEvent.Id, RemoteEventId = calendarEvent.Id,
IsLocked = calendarEvent.Locked.GetValueOrDefault(), IsLocked = calendarEvent.Locked.GetValueOrDefault(),
@@ -429,6 +431,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) public Task<bool> HasAccountAnyDraftAsync(Guid accountId)
=> MailService.HasAccountAnyDraftAsync(accountId); => MailService.HasAccountAnyDraftAsync(accountId);
@@ -135,6 +135,24 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
savingItem.Visibility = CalendarItemVisibility.Public; 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 // Set IsLocked based on whether the user is the organizer
// Read-only events are those where the current user is not the organizer // Read-only events are those where the current user is not the organizer
savingItem.IsLocked = calendarEvent.IsOrganizer.HasValue && !calendarEvent.IsOrganizer.Value; savingItem.IsLocked = calendarEvent.IsOrganizer.HasValue && !calendarEvent.IsOrganizer.Value;
@@ -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));
}
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));
}
else
{
// Fallback: just notify with current item to trigger refresh
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item));
}
}
}
+5 -2
View File
@@ -145,8 +145,11 @@ public class WinoRequestDelegator : IWinoRequestDelegator
CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
CalendarSynchronizerOperation.TentativeEvent => new TentativeEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), CalendarSynchronizerOperation.TentativeEvent => new TentativeEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
// Future support for update operations CalendarSynchronizerOperation.UpdateEvent => new UpdateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees)
// 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.") _ => throw new NotImplementedException($"Calendar operation {calendarPreparationRequest.Operation} is not implemented yet.")
}; };
+127 -1
View File
@@ -1561,6 +1561,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var fromHeaderValue = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? ""; var fromHeaderValue = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
var (fromName, fromAddress) = ExtractNameAndEmailFromHeader(fromHeaderValue); var (fromName, fromAddress) = ExtractNameAndEmailFromHeader(fromHeaderValue);
// Detect calendar invitation by checking Content-Type header (only if calendar access granted)
var itemType = Account.IsCalendarAccessGranted ? GetMailItemTypeFromHeaders(gmailMessage.Payload?.Headers) : MailItemType.Mail;
var copy = new MailCopy() var copy = new MailCopy()
{ {
CreationDate = creationDate, CreationDate = creationDate,
@@ -1579,7 +1582,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
InReplyTo = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("In-Reply-To", StringComparison.OrdinalIgnoreCase))?.Value, InReplyTo = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("In-Reply-To", StringComparison.OrdinalIgnoreCase))?.Value,
MessageId = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Message-Id", StringComparison.OrdinalIgnoreCase))?.Value, MessageId = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Message-Id", StringComparison.OrdinalIgnoreCase))?.Value,
References = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("References", StringComparison.OrdinalIgnoreCase))?.Value, References = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("References", StringComparison.OrdinalIgnoreCase))?.Value,
FileId = Guid.NewGuid() FileId = Guid.NewGuid(),
ItemType = itemType
}; };
// Set DraftId if this is a draft // Set DraftId if this is a draft
@@ -1589,6 +1593,47 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
return Task.FromResult(copy); return Task.FromResult(copy);
} }
/// <summary>
/// Determines MailItemType based on Gmail message headers.
/// Gmail doesn't have EventMessage type like Outlook, but calendar invitations can be detected
/// by checking Content-Type header for text/calendar or multipart/alternative with text/calendar part.
/// </summary>
private static MailItemType GetMailItemTypeFromHeaders(IList<MessagePartHeader> headers)
{
if (headers == null) return MailItemType.Mail;
// Check Content-Type header for text/calendar
var contentTypeHeader = headers.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))?.Value;
if (!string.IsNullOrEmpty(contentTypeHeader))
{
// Check if it's a calendar message (text/calendar or multipart with calendar)
if (contentTypeHeader.Contains("text/calendar", StringComparison.OrdinalIgnoreCase))
{
// Check the METHOD parameter to determine invitation type
var methodMatch = System.Text.RegularExpressions.Regex.Match(contentTypeHeader, @"method=([^;\s]+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (methodMatch.Success)
{
var method = methodMatch.Groups[1].Value.Trim('"').ToUpperInvariant();
return method switch
{
"REQUEST" => MailItemType.CalendarInvitation,
"CANCEL" => MailItemType.CalendarCancellation,
"REPLY" => MailItemType.CalendarResponse,
_ => MailItemType.Mail
};
}
// If no method specified, assume it's an invitation
return MailItemType.CalendarInvitation;
}
}
return MailItemType.Mail;
}
/// <summary> /// <summary>
/// Extracts name and email address from a header value like "Name <email@domain.com>" or "email@domain.com" /// Extracts name and email address from a header value like "Name <email@domain.com>" or "email@domain.com"
/// </summary> /// </summary>
@@ -1849,6 +1894,87 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)]; return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
} }
public override List<IRequestBundle<IClientServiceRequest>> UpdateCalendarEvent(UpdateCalendarEventRequest request)
{
var calendarItem = request.Item;
var attendees = request.Attendees;
// Get the calendar for this event
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot update event without remote event ID");
}
// Convert CalendarItem to Google Event for update
var googleEvent = new Event
{
Summary = calendarItem.Title,
Description = calendarItem.Description,
Location = calendarItem.Location,
Status = calendarItem.Status == CalendarItemStatus.Accepted ? "confirmed" : "tentative",
Transparency = calendarItem.ShowAs == CalendarItemShowAs.Free ? "transparent" : "opaque"
};
// Set start and end time with proper timezone handling
// CalendarItem stores dates in the event's timezone (StartTimeZone/EndTimeZone)
// When user edits in local timezone, the dates are already converted and stored correctly
if (calendarItem.IsAllDayEvent)
{
// All-day events use Date instead of DateTime
googleEvent.Start = new EventDateTime
{
Date = calendarItem.StartDate.ToString("yyyy-MM-dd")
};
googleEvent.End = new EventDateTime
{
Date = calendarItem.EndDate.ToString("yyyy-MM-dd")
};
}
else
{
// Regular events with time
// StartDate and EndDate are stored in the event's timezone
// We preserve the timezone information during update
googleEvent.Start = new EventDateTime
{
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.StartDate, TimeSpan.Zero),
TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id
};
googleEvent.End = new EventDateTime
{
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.EndDate, TimeSpan.Zero),
TimeZone = calendarItem.EndTimeZone ?? TimeZoneInfo.Local.Id
};
}
// Add attendees if any
if (attendees != null && attendees.Count > 0)
{
googleEvent.Attendees = attendees.Select(a => new EventAttendee
{
Email = a.Email,
DisplayName = a.Name,
Optional = a.IsOptionalAttendee
}).ToList();
}
// Update the event using Google Calendar API
var updateRequest = _calendarService.Events.Update(googleEvent, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
// Send notifications to attendees if the event has attendees
updateRequest.SendUpdates = (attendees != null && attendees.Count > 0)
? Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(updateRequest, request)];
}
#endregion #endregion
public override async Task KillSynchronizerAsync() public override async Task KillSynchronizerAsync()
+197 -13
View File
@@ -210,6 +210,17 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
config.QueryParameters.Select = outlookMessageSelectParameters; config.QueryParameters.Select = outlookMessageSelectParameters;
}, cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false);
// Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted)
if (Account.IsCalendarAccessGranted && message is EventMessage)
{
message = await FetchEventMessageAsync(message.Id, cancellationToken).ConfigureAwait(false);
if (message == null)
{
_logger.Warning("Failed to fetch EventMessage {MessageId}, skipping", messageId);
return;
}
}
var mailPackages = await CreateNewMailPackagesAsync(message, assignedFolder, cancellationToken).ConfigureAwait(false); var mailPackages = await CreateNewMailPackagesAsync(message, assignedFolder, cancellationToken).ConfigureAwait(false);
if (mailPackages == null) return; if (mailPackages == null) return;
@@ -291,6 +302,16 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
if (!IsResourceDeleted(message.AdditionalData) && !IsNotRealMessageType(message)) if (!IsResourceDeleted(message.AdditionalData) && !IsNotRealMessageType(message))
{ {
// Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted)
if (Account.IsCalendarAccessGranted && message is EventMessage)
{
message = await FetchEventMessageAsync(message.Id, cancellationToken).ConfigureAwait(false);
if (message == null)
{
return true; // Skip this message if fetch failed
}
}
// Check if message already exists // Check if message already exists
bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(message.Id, folder.Id).ConfigureAwait(false); bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(message.Id, folder.Id).ConfigureAwait(false);
@@ -567,6 +588,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
mailCopy.UniqueId = Guid.NewGuid(); mailCopy.UniqueId = Guid.NewGuid();
mailCopy.FileId = Guid.NewGuid(); mailCopy.FileId = Guid.NewGuid();
// Set ItemType based on calendar access permissions
if (Account.IsCalendarAccessGranted && message is EventMessage)
{
mailCopy.ItemType = message.GetMailItemType();
}
// Check for draft mapping if this is a draft with WinoLocalDraftHeader // Check for draft mapping if this is a draft with WinoLocalDraftHeader
if (message.IsDraft.GetValueOrDefault() && message.InternetMessageHeaders != null) if (message.IsDraft.GetValueOrDefault() && message.InternetMessageHeaders != null)
{ {
@@ -604,6 +631,28 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
private string GetDeltaTokenFromDeltaLink(string deltaLink) private string GetDeltaTokenFromDeltaLink(string deltaLink)
=> Regex.Split(deltaLink, "deltatoken=")[1]; => Regex.Split(deltaLink, "deltatoken=")[1];
/// <summary>
/// Determines MailItemType based on EventMessage's MeetingMessageType.
/// </summary>
private static MailItemType GetMailItemType(EventMessage eventMessage)
{
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 to CalendarInvitation if type is unknown
return MailItemType.CalendarInvitation;
}
protected override async Task<MailCopy> CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) protected override async Task<MailCopy> CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{ {
// Use centralized method // Use centralized method
@@ -614,10 +663,18 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
{ {
try try
{ {
return await _graphClient.Me.Messages[messageId].GetAsync((config) => var message = await _graphClient.Me.Messages[messageId].GetAsync((config) =>
{ {
config.QueryParameters.Select = outlookMessageSelectParameters; config.QueryParameters.Select = outlookMessageSelectParameters;
}, cancellationToken).ConfigureAwait(false); }, cancellationToken).ConfigureAwait(false);
// Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted)
if (Account.IsCalendarAccessGranted && message is EventMessage)
{
message = await FetchEventMessageAsync(message.Id, cancellationToken).ConfigureAwait(false);
}
return message;
} }
catch (ServiceException serviceException) catch (ServiceException serviceException)
{ {
@@ -738,6 +795,36 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
private bool IsResourceDeleted(IDictionary<string, object> additionalData) private bool IsResourceDeleted(IDictionary<string, object> additionalData)
=> additionalData != null && additionalData.ContainsKey("@removed"); => additionalData != null && additionalData.ContainsKey("@removed");
/// <summary>
/// Fetches an EventMessage with full details including MeetingMessageType from the Messages endpoint.
/// This is necessary because MeetingMessageType is not available when fetching as Message type.
/// </summary>
private async Task<EventMessage> FetchEventMessageAsync(string messageId, CancellationToken cancellationToken)
{
try
{
var requestInfo = _graphClient.Me.Messages[messageId].ToGetRequestInformation((config) =>
{
config.QueryParameters.Select = outlookMessageSelectParameters.Concat(["MeetingMessageType"]).ToArray();
});
var eventMessage = await _graphClient.Me.Messages[messageId].GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
var odataType = eventMessage?.AdditionalData?.ContainsKey("@odata.type") == true
? eventMessage.AdditionalData["@odata.type"]?.ToString()
: "unknown";
_logger.Debug("Fetched EventMessage {MessageId} with type {ODataType}", messageId, odataType);
return eventMessage as EventMessage;
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to fetch EventMessage {MessageId}", messageId);
return null;
}
}
private async Task<bool> HandleFolderRetrievedAsync(MailFolder folder, OutlookSpecialFolderIdInformation outlookSpecialFolderIdInformation, CancellationToken cancellationToken = default) private async Task<bool> HandleFolderRetrievedAsync(MailFolder folder, OutlookSpecialFolderIdInformation outlookSpecialFolderIdInformation, CancellationToken cancellationToken = default)
{ {
if (IsResourceDeleted(folder.AdditionalData)) if (IsResourceDeleted(folder.AdditionalData))
@@ -785,11 +872,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
/// Basically deleted item retention items are stored as Message object in Deleted Items folder. /// Basically deleted item retention items are stored as Message object in Deleted Items folder.
/// Suprisingly, odatatype will also be the same as Message. /// Suprisingly, odatatype will also be the same as Message.
/// In order to differentiate them from regular messages, we need to check the addresses in the message. /// In order to differentiate them from regular messages, we need to check the addresses in the message.
/// EventMessage types (calendar invitations/responses) are now processed as regular mail items with appropriate ItemType.
/// </summary> /// </summary>
/// <param name="item">Retrieved message.</param> /// <param name="item">Retrieved message.</param>
/// <returns>Whether the item is non-Message type or not.</returns> /// <returns>Whether the item is non-Message type or not.</returns>
private bool IsNotRealMessageType(Message item) private bool IsNotRealMessageType(Message item)
=> item is EventMessage || item.From?.EmailAddress == null; => item.From?.EmailAddress == null;
private async Task<bool> HandleItemRetrievedAsync(Message item, MailItemFolder folder, IList<string> downloadedMessageIds, CancellationToken cancellationToken = default) private async Task<bool> HandleItemRetrievedAsync(Message item, MailItemFolder folder, IList<string> downloadedMessageIds, CancellationToken cancellationToken = default)
{ {
@@ -802,6 +890,16 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
} }
else else
{ {
// Check if this is an EventMessage and fetch it separately if needed (only if calendar access granted)
if (Account.IsCalendarAccessGranted && item is EventMessage)
{
item = await FetchEventMessageAsync(item.Id, cancellationToken).ConfigureAwait(false);
if (item == null)
{
return true; // Skip this message if fetch failed
}
}
// If the item exists in the local database, it means that it's already downloaded. Process as an Update. // If the item exists in the local database, it means that it's already downloaded. Process as an Update.
var isMailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(item.Id, folder.Id); var isMailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(item.Id, folder.Id);
@@ -828,15 +926,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
{ {
if (IsNotRealMessageType(item)) if (IsNotRealMessageType(item))
{ {
if (item is EventMessage eventMessage) // EventMessages are handled above if calendar access is granted
{ // This catches non-message types like contacts or todo items
Log.Warning("Recieved event message. This is not supported yet. {Id}", eventMessage.Id); Log.Warning("Received non-message item type (contact/todo). This is not supported yet. {Id}", item.Id);
}
else
{
Log.Warning("Recieved either contact or todo item as message This is not supported yet. {Id}", item.Id);
}
return true; return true;
} }
@@ -1914,16 +2006,18 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
else else
{ {
// Regular events with time // Regular events with time
// StartDate and EndDate are stored in the event's timezone
// We preserve the timezone information during creation
outlookEvent.IsAllDay = false; outlookEvent.IsAllDay = false;
outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone
{ {
DateTime = calendarItem.StartDate.ToString("yyyy-MM-ddTHH:mm:ss"), DateTime = calendarItem.StartDate.ToString("yyyy-MM-ddTHH:mm:ss"),
TimeZone = calendarItem.StartTimeZone ?? "UTC" TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id
}; };
outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone
{ {
DateTime = calendarItem.EndDate.ToString("yyyy-MM-ddTHH:mm:ss"), DateTime = calendarItem.EndDate.ToString("yyyy-MM-ddTHH:mm:ss"),
TimeZone = calendarItem.EndTimeZone ?? "UTC" TimeZone = calendarItem.EndTimeZone ?? TimeZoneInfo.Local.Id
}; };
} }
@@ -2021,6 +2115,96 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
return [new HttpRequestBundle<RequestInformation>(tentativelyAcceptRequestInfo, request)]; return [new HttpRequestBundle<RequestInformation>(tentativelyAcceptRequestInfo, request)];
} }
public override List<IRequestBundle<RequestInformation>> UpdateCalendarEvent(UpdateCalendarEventRequest request)
{
var calendarItem = request.Item;
var attendees = request.Attendees;
// Get the calendar for this event
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
// Convert CalendarItem to Outlook Event for update
var outlookEvent = new Microsoft.Graph.Models.Event
{
Subject = calendarItem.Title,
Body = new Microsoft.Graph.Models.ItemBody
{
ContentType = Microsoft.Graph.Models.BodyType.Text,
Content = calendarItem.Description
},
Location = new Microsoft.Graph.Models.Location
{
DisplayName = calendarItem.Location
},
ShowAs = calendarItem.ShowAs switch
{
CalendarItemShowAs.Free => Microsoft.Graph.Models.FreeBusyStatus.Free,
CalendarItemShowAs.Tentative => Microsoft.Graph.Models.FreeBusyStatus.Tentative,
CalendarItemShowAs.Busy => Microsoft.Graph.Models.FreeBusyStatus.Busy,
CalendarItemShowAs.OutOfOffice => Microsoft.Graph.Models.FreeBusyStatus.Oof,
CalendarItemShowAs.WorkingElsewhere => Microsoft.Graph.Models.FreeBusyStatus.WorkingElsewhere,
_ => Microsoft.Graph.Models.FreeBusyStatus.Busy
}
};
// Set start and end time using DateTimeTimeZone
if (calendarItem.IsAllDayEvent)
{
// All-day events
outlookEvent.IsAllDay = true;
outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone
{
DateTime = calendarItem.StartDate.ToString("yyyy-MM-dd"),
TimeZone = "UTC"
};
outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone
{
DateTime = calendarItem.EndDate.ToString("yyyy-MM-dd"),
TimeZone = "UTC"
};
}
else
{
// Regular events with time
// StartDate and EndDate are stored in the event's timezone
// We preserve the timezone information during update
outlookEvent.IsAllDay = false;
outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone
{
DateTime = calendarItem.StartDate.ToString("yyyy-MM-ddTHH:mm:ss"),
TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id
};
outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone
{
DateTime = calendarItem.EndDate.ToString("yyyy-MM-ddTHH:mm:ss"),
TimeZone = calendarItem.EndTimeZone ?? TimeZoneInfo.Local.Id
};
}
// Add attendees if any
if (attendees != null && attendees.Count > 0)
{
outlookEvent.Attendees = attendees.Select(a => new Microsoft.Graph.Models.Attendee
{
EmailAddress = new Microsoft.Graph.Models.EmailAddress
{
Address = a.Email,
Name = a.Name
},
Type = a.IsOptionalAttendee ? Microsoft.Graph.Models.AttendeeType.Optional : Microsoft.Graph.Models.AttendeeType.Required
}).ToList();
}
// Update the event using Graph API
var updateRequest = _graphClient.Me.Events[calendarItem.RemoteEventId].ToPatchRequestInformation(outlookEvent);
return [new HttpRequestBundle<RequestInformation>(updateRequest, request)];
}
#endregion #endregion
public override async Task KillSynchronizerAsync() public override async Task KillSynchronizerAsync()
+2 -1
View File
@@ -381,7 +381,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
nativeRequests.AddRange(TentativeEvent(group.ElementAt(0) as TentativeEventRequest)); nativeRequests.AddRange(TentativeEvent(group.ElementAt(0) as TentativeEventRequest));
break; break;
case CalendarSynchronizerOperation.UpdateEvent: case CalendarSynchronizerOperation.UpdateEvent:
// TODO: Implement UpdateCalendarEvent nativeRequests.AddRange(UpdateCalendarEvent(group.ElementAt(0) as UpdateCalendarEventRequest));
break; break;
case CalendarSynchronizerOperation.DeleteEvent: case CalendarSynchronizerOperation.DeleteEvent:
// TODO: Implement DeleteCalendarEvent // TODO: Implement DeleteCalendarEvent
@@ -510,6 +510,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
#region Calendar Operations #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>> 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>> AcceptEvent(AcceptEventRequest 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>> 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>> OutlookDeclineEvent(OutlookDeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
@@ -109,7 +109,8 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
Name = accountCreationDialogResult.AccountName, Name = accountCreationDialogResult.AccountName,
SpecialImapProvider = accountCreationDialogResult.SpecialImapProviderDetails?.SpecialImapProvider ?? SpecialImapProvider.None, SpecialImapProvider = accountCreationDialogResult.SpecialImapProviderDetails?.SpecialImapProvider ?? SpecialImapProvider.None,
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
AccountColorHex = accountCreationDialogResult.AccountColorHex AccountColorHex = accountCreationDialogResult.AccountColorHex,
IsCalendarAccessGranted = true // New accounts have calendar scopes
}; };
await creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource); await creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource);
@@ -1,6 +1,7 @@
using System; using System;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Enums;
using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Data;
namespace Wino.Mail.WinUI.Controls.ListView; namespace Wino.Mail.WinUI.Controls.ListView;
@@ -9,11 +10,18 @@ public partial class WinoMailItemTemplateSelector : DataTemplateSelector
{ {
public DataTemplate? SingleMailItemTemplate { get; set; } public DataTemplate? SingleMailItemTemplate { get; set; }
public DataTemplate? ThreadMailItemTemplate { get; set; } public DataTemplate? ThreadMailItemTemplate { get; set; }
public DataTemplate? CalendarMailItemTemplate { get; set; }
protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
{ {
if (item is MailItemViewModel) if (item is MailItemViewModel mailItemViewModel)
{
// Check if it's a calendar-related item
if (mailItemViewModel.MailCopy.ItemType != MailItemType.Mail && CalendarMailItemTemplate != null)
return CalendarMailItemTemplate;
return SingleMailItemTemplate ?? throw new Exception($"Missing template for single mail items."); return SingleMailItemTemplate ?? throw new Exception($"Missing template for single mail items.");
}
else if (item is ThreadMailItemViewModel) else if (item is ThreadMailItemViewModel)
return ThreadMailItemTemplate ?? throw new Exception($"Missing template for thread mail items."); return ThreadMailItemTemplate ?? throw new Exception($"Missing template for thread mail items.");
@@ -59,6 +59,9 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic
if (SetProperty(ref isEventDetailsVisible, value)) if (SetProperty(ref isEventDetailsVisible, value))
{ {
OnPropertyChanged(nameof(IsBackButtonVisible)); OnPropertyChanged(nameof(IsBackButtonVisible));
IsReaderNarrowed = value;
IsReadingMail = value;
} }
} }
} }
@@ -373,7 +373,7 @@
<Setter Target="CalendarHostListView.IsEnabled" Value="False" /> <Setter Target="CalendarHostListView.IsEnabled" Value="False" />
</VisualState.Setters> </VisualState.Setters>
<VisualState.StateTriggers> <VisualState.StateTriggers>
<StateTrigger IsActive="{x:Bind ViewModel.IsEventDetailsPageActive, Mode=OneWay}" /> <StateTrigger IsActive="{x:Bind ViewModel.StatePersistenceService.IsEventDetailsVisible, Mode=OneWay}" />
</VisualState.StateTriggers> </VisualState.StateTriggers>
</VisualState> </VisualState>
</VisualStateGroup> </VisualStateGroup>
@@ -121,8 +121,32 @@
</DataTemplate> </DataTemplate>
<!-- Calendar Mail Item Template (Placeholder for calendar invitations/responses) -->
<DataTemplate x:Key="CalendarMailItemTemplate" x:DataType="viewModelData:MailItemViewModel">
<Grid>
<TextBlock Text="Calendar invitation" />
</Grid>
<!--<controls:MailItemDisplayInformationControl
x:DefaultBindMode="OneWay"
ActionItem="{x:Bind}"
Base64ContactPicture="{x:Bind MailCopy.SenderContact.Base64ContactPicture, Mode=OneWay, TargetNullValue=''}"
ContextRequested="MailItemContextRequested"
CreationDate="{x:Bind CreationDate}"
FromAddress="{x:Bind FromAddress}"
FromName="{x:Bind FromName}"
HasAttachments="{x:Bind HasAttachments, Mode=OneWay}"
HoverActionExecuted="MailItemDisplayInformationControl_HoverActionExecuted"
IsDraft="{x:Bind IsDraft, Mode=OneWay}"
IsFlagged="{x:Bind IsFlagged, Mode=OneWay}"
IsRead="{x:Bind IsRead, Mode=OneWay}"
IsThumbnailUpdated="{x:Bind ThumbnailUpdatedEvent, Mode=OneWay}"
PreviewText="{x:Bind PreviewText, Mode=OneWay}"
Subject="{x:Bind Subject, Mode=OneWay}" />-->
</DataTemplate>
<listview:WinoMailItemTemplateSelector <listview:WinoMailItemTemplateSelector
x:Key="MailItemTemplateSelector" x:Key="MailItemTemplateSelector"
CalendarMailItemTemplate="{StaticResource CalendarMailItemTemplate}"
SingleMailItemTemplate="{StaticResource SingleMailItemTemplate}" SingleMailItemTemplate="{StaticResource SingleMailItemTemplate}"
ThreadMailItemTemplate="{StaticResource ThreadMailItemTemplate}" /> ThreadMailItemTemplate="{StaticResource ThreadMailItemTemplate}" />
@@ -1,6 +0,0 @@
namespace Wino.Messaging.Client.Calendar;
/// <summary>
/// When event details page is activated or deactivated.
/// </summary>
public record DetailsPageStateChangedMessage(bool IsActivated);
+14 -7
View File
@@ -12,7 +12,6 @@ using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Messaging.Client.Accounts; using Wino.Messaging.Client.Accounts;
using Wino.Messaging.UI; using Wino.Messaging.UI;
using Wino.Services.Extensions;
namespace Wino.Services; namespace Wino.Services;
@@ -21,6 +20,7 @@ public class AccountService : BaseDatabaseService, IAccountService
public IAuthenticator ExternalAuthenticationAuthenticator { get; set; } public IAuthenticator ExternalAuthenticationAuthenticator { get; set; }
private readonly ISignatureService _signatureService; private readonly ISignatureService _signatureService;
private readonly IAuthenticationProvider _authenticationProvider;
private readonly IMimeFileService _mimeFileService; private readonly IMimeFileService _mimeFileService;
private readonly IPreferencesService _preferencesService; private readonly IPreferencesService _preferencesService;
@@ -28,10 +28,12 @@ public class AccountService : BaseDatabaseService, IAccountService
public AccountService(IDatabaseService databaseService, public AccountService(IDatabaseService databaseService,
ISignatureService signatureService, ISignatureService signatureService,
IAuthenticationProvider authenticationProvider,
IMimeFileService mimeFileService, IMimeFileService mimeFileService,
IPreferencesService preferencesService) : base(databaseService) IPreferencesService preferencesService) : base(databaseService)
{ {
_signatureService = signatureService; _signatureService = signatureService;
_authenticationProvider = authenticationProvider;
_mimeFileService = mimeFileService; _mimeFileService = mimeFileService;
_preferencesService = preferencesService; _preferencesService = preferencesService;
} }
@@ -59,7 +61,7 @@ public class AccountService : BaseDatabaseService, IAccountService
var sql = $"UPDATE MailAccount SET MergedInboxId = ? WHERE Id IN ({placeholders})"; var sql = $"UPDATE MailAccount SET MergedInboxId = ? WHERE Id IN ({placeholders})";
var parameters = new List<object> { mergedInboxId }; var parameters = new List<object> { mergedInboxId };
parameters.AddRange(accountIdList.Cast<object>()); parameters.AddRange(accountIdList.Cast<object>());
await Connection.ExecuteAsync(sql, parameters.ToArray()); await Connection.ExecuteAsync(sql, parameters.ToArray());
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested()); WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested());
@@ -193,13 +195,18 @@ public class AccountService : BaseDatabaseService, IAccountService
if (account == null) return; if (account == null) return;
//var authenticator = _authenticationProvider.GetAuthenticator(account.ProviderType); var authenticator = _authenticationProvider.GetAuthenticator(account.ProviderType);
//// This will re-generate token. // This will re-generate token with interactive authentication
//var token = await authenticator.GenerateTokenInformationAsync(account); // New authentication will include calendar scopes
var token = await authenticator.GenerateTokenInformationAsync(account);
// TODO: Rest? Guard.IsNotNull(token);
// Guard.IsNotNull(token);
// Enable calendar access since new token includes calendar scopes
account.IsCalendarAccessGranted = true;
await UpdateAccountAsync(account);
} }
private Task<MailAccountPreferences> GetAccountPreferencesAsync(Guid accountId) private Task<MailAccountPreferences> GetAccountPreferencesAsync(Guid accountId)
@@ -111,6 +111,9 @@ public static class MailkitClientExtensions
// Use InternalDate (server received date) if available, otherwise fall back to Date header (sent date) // Use InternalDate (server received date) if available, otherwise fall back to Date header (sent date)
var creationDate = messageSummary.InternalDate?.UtcDateTime ?? mime.Date.UtcDateTime; var creationDate = messageSummary.InternalDate?.UtcDateTime ?? mime.Date.UtcDateTime;
// Detect calendar invitation based on MIME content type
var itemType = GetMailItemTypeFromMime(mime);
var copy = new MailCopy() var copy = new MailCopy()
{ {
Id = messageUid, Id = messageUid,
@@ -128,12 +131,49 @@ public static class MailkitClientExtensions
References = mime.References?.GetReferences(), References = mime.References?.GetReferences(),
InReplyTo = mime.GetInReplyTo(), InReplyTo = mime.GetInReplyTo(),
HasAttachments = mime.Attachments.Any(), HasAttachments = mime.Attachments.Any(),
FileId = Guid.NewGuid() FileId = Guid.NewGuid(),
ItemType = itemType
}; };
return copy; return copy;
} }
/// <summary>
/// Determines MailItemType based on MIME message content type.
/// Calendar invitations have text/calendar content type with METHOD parameter.
/// </summary>
private static MailItemType GetMailItemTypeFromMime(MimeMessage mime)
{
if (mime == null) return MailItemType.Mail;
// Check if the message contains text/calendar content
var calendarPart = mime.BodyParts.OfType<MimePart>()
.FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
if (calendarPart != null)
{
// Check the METHOD parameter to determine invitation type
var method = calendarPart.ContentType.Parameters
.FirstOrDefault(p => p.Name.Equals("method", StringComparison.OrdinalIgnoreCase))?.Value?.ToUpperInvariant();
if (!string.IsNullOrEmpty(method))
{
return method switch
{
"REQUEST" => MailItemType.CalendarInvitation,
"CANCEL" => MailItemType.CalendarCancellation,
"REPLY" => MailItemType.CalendarResponse,
_ => MailItemType.Mail
};
}
// If no method specified, assume it's an invitation
return MailItemType.CalendarInvitation;
}
return MailItemType.Mail;
}
// TODO: Name and Address parsing should be handled better. // TODO: Name and Address parsing should be handled better.
// At some point Wino needs better contact management. // At some point Wino needs better contact management.