Calendar invitations for Mail part of the app.
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user