Calendar invitations for Mail part of the app.
This commit is contained in:
@@ -98,7 +98,8 @@ public class OutlookAuthenticator : BaseAuthenticator, IOutlookAuthenticator
|
||||
catch (MsalUiRequiredException)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -27,17 +27,13 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
|
||||
IRecipient<VisibleDateRangeChangedMessage>,
|
||||
IRecipient<CalendarEnableStatusChangedMessage>,
|
||||
IRecipient<NavigateManageAccountsRequested>,
|
||||
IRecipient<CalendarDisplayTypeChangedMessage>,
|
||||
IRecipient<DetailsPageStateChangedMessage>
|
||||
IRecipient<CalendarDisplayTypeChangedMessage>
|
||||
{
|
||||
public IPreferencesService PreferencesService { get; }
|
||||
public IStatePersistanceService StatePersistenceService { get; }
|
||||
public IAccountCalendarStateService AccountCalendarStateService { get; }
|
||||
public INavigationService NavigationService { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isEventDetailsPageActive;
|
||||
|
||||
[ObservableProperty]
|
||||
private int _selectedMenuItemIndex = -1;
|
||||
|
||||
@@ -303,7 +299,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
|
||||
Messenger.Register<CalendarEnableStatusChangedMessage>(this);
|
||||
Messenger.Register<NavigateManageAccountsRequested>(this);
|
||||
Messenger.Register<CalendarDisplayTypeChangedMessage>(this);
|
||||
Messenger.Register<DetailsPageStateChangedMessage>(this);
|
||||
}
|
||||
|
||||
protected override void UnregisterRecipients()
|
||||
@@ -314,7 +309,6 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
|
||||
Messenger.Unregister<CalendarEnableStatusChangedMessage>(this);
|
||||
Messenger.Unregister<NavigateManageAccountsRequested>(this);
|
||||
Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this);
|
||||
Messenger.Unregister<DetailsPageStateChangedMessage>(this);
|
||||
}
|
||||
|
||||
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(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);
|
||||
|
||||
Messenger.Send(new DetailsPageStateChangedMessage(true));
|
||||
|
||||
if (parameters == null || parameters is not CalendarItemTarget args)
|
||||
return;
|
||||
|
||||
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)
|
||||
{
|
||||
base.OnCalendarItemDeleted(calendarItem);
|
||||
@@ -205,6 +220,9 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
|
||||
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
|
||||
Reminders = await _calendarService.GetRemindersAsync(currentEventItem.EventTrackingId);
|
||||
InitializeReminderOptions();
|
||||
@@ -221,15 +239,21 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
private async Task LoadAttendeesAsync(Guid eventTrackingId, CalendarItem calendarItem)
|
||||
{
|
||||
CurrentEvent.Attendees.Clear();
|
||||
|
||||
|
||||
var attendees = await _calendarService.GetAttendeesAsync(eventTrackingId);
|
||||
|
||||
// Check if organizer is in the attendees list
|
||||
var hasOrganizerInList = attendees.Any(a => a.IsOrganizer);
|
||||
// Separate organizer from other attendees to ensure organizer is always first
|
||||
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 (!hasOrganizerInList && !string.IsNullOrEmpty(calendarItem.OrganizerEmail))
|
||||
// If the organizer is in the list, add them first
|
||||
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
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -242,7 +266,8 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
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);
|
||||
}
|
||||
@@ -321,6 +346,10 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
|
||||
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
|
||||
var selectedOptions = ReminderOptions.Where(o => o.IsSelected).ToList();
|
||||
|
||||
@@ -344,12 +373,35 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
|
||||
await _calendarService.SaveRemindersAsync(CurrentEvent.CalendarItem.EventTrackingId, 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();
|
||||
// TODO: Implement saving other event details
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
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 CalendarItemStatus Status { 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 UpdatedAt { get; set; }
|
||||
public Guid CalendarId { get; set; }
|
||||
|
||||
@@ -103,6 +103,11 @@ public class MailCopy
|
||||
/// </summary>
|
||||
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>
|
||||
/// Assigned draft id.
|
||||
/// </summary>
|
||||
|
||||
@@ -78,6 +78,14 @@ public class MailAccount
|
||||
/// </summary>
|
||||
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>
|
||||
/// Contains the merged inbox this account belongs to.
|
||||
/// Ignored for all SQLite operations.
|
||||
|
||||
@@ -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="Attendees">List of attendees for the calendar event.</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,
|
||||
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
|
||||
};
|
||||
|
||||
if (mailCopy.IsDraft)
|
||||
@@ -69,6 +70,51 @@ public static class OutlookIntegratorExtensions
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
// 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.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,
|
||||
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(),
|
||||
@@ -148,6 +149,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
Title = calendarEvent.Summary,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
Visibility = GetVisibility(calendarEvent.Visibility),
|
||||
ShowAs = GetShowAs(calendarEvent.Transparency),
|
||||
HtmlLink = calendarEvent.HtmlLink,
|
||||
RemoteEventId = calendarEvent.Id,
|
||||
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)
|
||||
=> MailService.HasAccountAnyDraftAsync(accountId);
|
||||
|
||||
|
||||
@@ -135,6 +135,24 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,8 +145,11 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage),
|
||||
CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(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.")
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (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()
|
||||
{
|
||||
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,
|
||||
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,
|
||||
FileId = Guid.NewGuid()
|
||||
FileId = Guid.NewGuid(),
|
||||
ItemType = itemType
|
||||
};
|
||||
|
||||
// Set DraftId if this is a draft
|
||||
@@ -1589,6 +1593,47 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
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>
|
||||
/// Extracts name and email address from a header value like "Name <email@domain.com>" or "email@domain.com"
|
||||
/// </summary>
|
||||
@@ -1849,6 +1894,87 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
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
|
||||
|
||||
public override async Task KillSynchronizerAsync()
|
||||
|
||||
@@ -210,6 +210,17 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
config.QueryParameters.Select = outlookMessageSelectParameters;
|
||||
}, 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);
|
||||
|
||||
if (mailPackages == null) return;
|
||||
@@ -291,6 +302,16 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, 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
|
||||
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.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
|
||||
if (message.IsDraft.GetValueOrDefault() && message.InternetMessageHeaders != null)
|
||||
{
|
||||
@@ -604,6 +631,28 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
private string GetDeltaTokenFromDeltaLink(string deltaLink)
|
||||
=> 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)
|
||||
{
|
||||
// Use centralized method
|
||||
@@ -614,10 +663,18 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _graphClient.Me.Messages[messageId].GetAsync((config) =>
|
||||
var message = await _graphClient.Me.Messages[messageId].GetAsync((config) =>
|
||||
{
|
||||
config.QueryParameters.Select = outlookMessageSelectParameters;
|
||||
}, 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)
|
||||
{
|
||||
@@ -738,6 +795,36 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
private bool IsResourceDeleted(IDictionary<string, object> additionalData)
|
||||
=> 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)
|
||||
{
|
||||
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.
|
||||
/// 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.
|
||||
/// EventMessage types (calendar invitations/responses) are now processed as regular mail items with appropriate ItemType.
|
||||
/// </summary>
|
||||
/// <param name="item">Retrieved message.</param>
|
||||
/// <returns>Whether the item is non-Message type or not.</returns>
|
||||
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)
|
||||
{
|
||||
@@ -802,6 +890,16 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
}
|
||||
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.
|
||||
|
||||
var isMailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(item.Id, folder.Id);
|
||||
@@ -828,15 +926,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
{
|
||||
if (IsNotRealMessageType(item))
|
||||
{
|
||||
if (item is EventMessage eventMessage)
|
||||
{
|
||||
Log.Warning("Recieved event message. This is not supported yet. {Id}", eventMessage.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Recieved either contact or todo item as message This is not supported yet. {Id}", item.Id);
|
||||
}
|
||||
|
||||
// EventMessages are handled above if calendar access is granted
|
||||
// This catches non-message types like contacts or todo items
|
||||
Log.Warning("Received non-message item type (contact/todo). This is not supported yet. {Id}", item.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1914,16 +2006,18 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
else
|
||||
{
|
||||
// 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.Start = new Microsoft.Graph.Models.DateTimeTimeZone
|
||||
{
|
||||
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
|
||||
{
|
||||
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)];
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
public override async Task KillSynchronizerAsync()
|
||||
|
||||
@@ -381,7 +381,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
nativeRequests.AddRange(TentativeEvent(group.ElementAt(0) as TentativeEventRequest));
|
||||
break;
|
||||
case CalendarSynchronizerOperation.UpdateEvent:
|
||||
// TODO: Implement UpdateCalendarEvent
|
||||
nativeRequests.AddRange(UpdateCalendarEvent(group.ElementAt(0) as UpdateCalendarEventRequest));
|
||||
break;
|
||||
case CalendarSynchronizerOperation.DeleteEvent:
|
||||
// TODO: Implement DeleteCalendarEvent
|
||||
@@ -510,6 +510,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
#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>> 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()));
|
||||
|
||||
@@ -109,7 +109,8 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
|
||||
Name = accountCreationDialogResult.AccountName,
|
||||
SpecialImapProvider = accountCreationDialogResult.SpecialImapProviderDetails?.SpecialImapProvider ?? SpecialImapProvider.None,
|
||||
Id = Guid.NewGuid(),
|
||||
AccountColorHex = accountCreationDialogResult.AccountColorHex
|
||||
AccountColorHex = accountCreationDialogResult.AccountColorHex,
|
||||
IsCalendarAccessGranted = true // New accounts have calendar scopes
|
||||
};
|
||||
|
||||
await creationDialog.ShowDialogAsync(accountCreationCancellationTokenSource);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Mail.ViewModels.Data;
|
||||
|
||||
namespace Wino.Mail.WinUI.Controls.ListView;
|
||||
@@ -9,11 +10,18 @@ public partial class WinoMailItemTemplateSelector : DataTemplateSelector
|
||||
{
|
||||
public DataTemplate? SingleMailItemTemplate { get; set; }
|
||||
public DataTemplate? ThreadMailItemTemplate { get; set; }
|
||||
public DataTemplate? CalendarMailItemTemplate { get; set; }
|
||||
|
||||
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.");
|
||||
}
|
||||
else if (item is ThreadMailItemViewModel)
|
||||
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))
|
||||
{
|
||||
OnPropertyChanged(nameof(IsBackButtonVisible));
|
||||
|
||||
IsReaderNarrowed = value;
|
||||
IsReadingMail = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@
|
||||
<Setter Target="CalendarHostListView.IsEnabled" Value="False" />
|
||||
</VisualState.Setters>
|
||||
<VisualState.StateTriggers>
|
||||
<StateTrigger IsActive="{x:Bind ViewModel.IsEventDetailsPageActive, Mode=OneWay}" />
|
||||
<StateTrigger IsActive="{x:Bind ViewModel.StatePersistenceService.IsEventDetailsVisible, Mode=OneWay}" />
|
||||
</VisualState.StateTriggers>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
|
||||
@@ -121,8 +121,32 @@
|
||||
|
||||
</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
|
||||
x:Key="MailItemTemplateSelector"
|
||||
CalendarMailItemTemplate="{StaticResource CalendarMailItemTemplate}"
|
||||
SingleMailItemTemplate="{StaticResource SingleMailItemTemplate}"
|
||||
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);
|
||||
@@ -12,7 +12,6 @@ using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Messaging.Client.Accounts;
|
||||
using Wino.Messaging.UI;
|
||||
using Wino.Services.Extensions;
|
||||
|
||||
namespace Wino.Services;
|
||||
|
||||
@@ -21,6 +20,7 @@ public class AccountService : BaseDatabaseService, IAccountService
|
||||
public IAuthenticator ExternalAuthenticationAuthenticator { get; set; }
|
||||
|
||||
private readonly ISignatureService _signatureService;
|
||||
private readonly IAuthenticationProvider _authenticationProvider;
|
||||
private readonly IMimeFileService _mimeFileService;
|
||||
private readonly IPreferencesService _preferencesService;
|
||||
|
||||
@@ -28,10 +28,12 @@ public class AccountService : BaseDatabaseService, IAccountService
|
||||
|
||||
public AccountService(IDatabaseService databaseService,
|
||||
ISignatureService signatureService,
|
||||
IAuthenticationProvider authenticationProvider,
|
||||
IMimeFileService mimeFileService,
|
||||
IPreferencesService preferencesService) : base(databaseService)
|
||||
{
|
||||
_signatureService = signatureService;
|
||||
_authenticationProvider = authenticationProvider;
|
||||
_mimeFileService = mimeFileService;
|
||||
_preferencesService = preferencesService;
|
||||
}
|
||||
@@ -59,7 +61,7 @@ public class AccountService : BaseDatabaseService, IAccountService
|
||||
var sql = $"UPDATE MailAccount SET MergedInboxId = ? WHERE Id IN ({placeholders})";
|
||||
var parameters = new List<object> { mergedInboxId };
|
||||
parameters.AddRange(accountIdList.Cast<object>());
|
||||
|
||||
|
||||
await Connection.ExecuteAsync(sql, parameters.ToArray());
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested());
|
||||
@@ -193,13 +195,18 @@ public class AccountService : BaseDatabaseService, IAccountService
|
||||
|
||||
if (account == null) return;
|
||||
|
||||
//var authenticator = _authenticationProvider.GetAuthenticator(account.ProviderType);
|
||||
var authenticator = _authenticationProvider.GetAuthenticator(account.ProviderType);
|
||||
|
||||
//// This will re-generate token.
|
||||
//var token = await authenticator.GenerateTokenInformationAsync(account);
|
||||
// This will re-generate token with interactive authentication
|
||||
// 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)
|
||||
|
||||
@@ -111,6 +111,9 @@ public static class MailkitClientExtensions
|
||||
// Use InternalDate (server received date) if available, otherwise fall back to Date header (sent date)
|
||||
var creationDate = messageSummary.InternalDate?.UtcDateTime ?? mime.Date.UtcDateTime;
|
||||
|
||||
// Detect calendar invitation based on MIME content type
|
||||
var itemType = GetMailItemTypeFromMime(mime);
|
||||
|
||||
var copy = new MailCopy()
|
||||
{
|
||||
Id = messageUid,
|
||||
@@ -128,12 +131,49 @@ public static class MailkitClientExtensions
|
||||
References = mime.References?.GetReferences(),
|
||||
InReplyTo = mime.GetInReplyTo(),
|
||||
HasAttachments = mime.Attachments.Any(),
|
||||
FileId = Guid.NewGuid()
|
||||
FileId = Guid.NewGuid(),
|
||||
ItemType = itemType
|
||||
};
|
||||
|
||||
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.
|
||||
// At some point Wino needs better contact management.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user