Calendar invitations for Mail part of the app.

This commit is contained in:
Burak Kaan Köse
2026-01-05 00:21:07 +01:00
parent 0b0f6b8d8e
commit 3d07328f47
24 changed files with 679 additions and 66 deletions
+127 -1
View File
@@ -1561,6 +1561,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var fromHeaderValue = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
var (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()
+197 -13
View File
@@ -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()
+2 -1
View File
@@ -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()));