Calendar - mail mapping.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using MimeKit;
|
||||
|
||||
namespace Wino.Core.Extensions;
|
||||
|
||||
public static class CalendarInvitationExtensions
|
||||
{
|
||||
public static string ExtractInvitationUid(this MimeMessage message)
|
||||
{
|
||||
if (message == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var icsContent = GetCalendarContent(message);
|
||||
if (string.IsNullOrWhiteSpace(icsContent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var unfolded = UnfoldIcs(icsContent);
|
||||
var veventSection = ExtractFirstVEventSection(unfolded);
|
||||
if (string.IsNullOrWhiteSpace(veventSection))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return TryReadIcsProperty(veventSection, "UID", out var uid)
|
||||
? uid
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string GetCalendarContent(MimeMessage message)
|
||||
{
|
||||
var textPart = message.BodyParts
|
||||
.OfType<TextPart>()
|
||||
.FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (textPart != null)
|
||||
{
|
||||
return textPart.Text;
|
||||
}
|
||||
|
||||
var mimePart = message.BodyParts
|
||||
.OfType<MimePart>()
|
||||
.FirstOrDefault(p => p.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (mimePart == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
mimePart.Content.DecodeTo(stream);
|
||||
var bytes = stream.ToArray();
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var charset = mimePart.ContentType?.Charset;
|
||||
var encoding = string.IsNullOrWhiteSpace(charset) ? Encoding.UTF8 : Encoding.GetEncoding(charset);
|
||||
return encoding.GetString(bytes);
|
||||
}
|
||||
|
||||
private static string UnfoldIcs(string content)
|
||||
=> content
|
||||
.Replace("\r\n ", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\r\n\t", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n ", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n\t", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
private static string ExtractFirstVEventSection(string ics)
|
||||
{
|
||||
const string beginVevent = "BEGIN:VEVENT";
|
||||
const string endVevent = "END:VEVENT";
|
||||
|
||||
var beginIndex = ics.IndexOf(beginVevent, StringComparison.OrdinalIgnoreCase);
|
||||
if (beginIndex < 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var endIndex = ics.IndexOf(endVevent, beginIndex, StringComparison.OrdinalIgnoreCase);
|
||||
if (endIndex < 0)
|
||||
{
|
||||
return ics[beginIndex..];
|
||||
}
|
||||
|
||||
return ics.Substring(beginIndex, endIndex - beginIndex + endVevent.Length);
|
||||
}
|
||||
|
||||
private static bool TryReadIcsProperty(string icsSection, string propertyName, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
var lines = icsSection.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (!line.StartsWith(propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var colonIndex = line.IndexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex >= line.Length - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
value = line[(colonIndex + 1)..].Trim();
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public interface IDefaultChangeProcessor
|
||||
|
||||
Task DeleteCalendarItemAsync(Guid calendarItemId);
|
||||
Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId);
|
||||
Task<CalendarItem> GetCalendarItemAsync(Guid calendarId, string remoteEventId);
|
||||
|
||||
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||
@@ -54,6 +55,8 @@ public interface IDefaultChangeProcessor
|
||||
Task<List<MailCopy>> GetMailCopiesAsync(IEnumerable<string> mailCopyIds);
|
||||
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
|
||||
Task DeleteUserMailCacheAsync(Guid accountId);
|
||||
Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping);
|
||||
Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the mail exists in the folder.
|
||||
@@ -199,6 +202,9 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
|
||||
public Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId)
|
||||
=> CalendarService.DeleteCalendarItemAsync(calendarRemoteEventId, calendarId);
|
||||
|
||||
public Task<CalendarItem> GetCalendarItemAsync(Guid calendarId, string remoteEventId)
|
||||
=> CalendarService.GetCalendarItemAsync(calendarId, remoteEventId);
|
||||
|
||||
public Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar)
|
||||
=> CalendarService.DeleteAccountCalendarAsync(accountCalendar);
|
||||
|
||||
@@ -217,6 +223,43 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
|
||||
await AccountService.DeleteAccountMailCacheAsync(accountId, AccountCacheResetReason.ExpiredCache).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping)
|
||||
{
|
||||
if (mapping == null || mapping.AccountId == Guid.Empty || string.IsNullOrWhiteSpace(mapping.MailCopyId))
|
||||
return;
|
||||
|
||||
var existing = await Connection.Table<MailInvitationCalendarMapping>()
|
||||
.FirstOrDefaultAsync(x => x.AccountId == mapping.AccountId && x.MailCopyId == mapping.MailCopyId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
if (mapping.Id == Guid.Empty)
|
||||
mapping.Id = Guid.NewGuid();
|
||||
|
||||
mapping.UpdatedAtUtc = DateTime.UtcNow;
|
||||
await Connection.InsertAsync(mapping, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
existing.InvitationUid = mapping.InvitationUid;
|
||||
existing.CalendarId = mapping.CalendarId;
|
||||
existing.CalendarItemId = mapping.CalendarItemId;
|
||||
existing.CalendarRemoteEventId = mapping.CalendarRemoteEventId;
|
||||
existing.UpdatedAtUtc = DateTime.UtcNow;
|
||||
|
||||
await Connection.UpdateAsync(existing, typeof(MailInvitationCalendarMapping)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId)
|
||||
{
|
||||
if (accountId == Guid.Empty || string.IsNullOrWhiteSpace(mailCopyId))
|
||||
return Task.FromResult<MailInvitationCalendarMapping>(null);
|
||||
|
||||
return Connection.Table<MailInvitationCalendarMapping>()
|
||||
.FirstOrDefaultAsync(x => x.AccountId == accountId && x.MailCopyId == mailCopyId);
|
||||
}
|
||||
|
||||
public Task<bool> IsMailExistsInFolderAsync(string messageId, Guid folderId)
|
||||
=> MailService.IsMailExistsAsync(messageId, folderId);
|
||||
}
|
||||
|
||||
@@ -1965,12 +1965,35 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
// Create base MailCopy from metadata only - NO MIME download
|
||||
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
||||
|
||||
// Initial sync metadata flow does not include MIME, but calendar invitations need MIME
|
||||
// for date rendering and invitation-to-calendar mapping.
|
||||
if (mimeMessage == null && baseMailCopy?.ItemType == MailItemType.CalendarInvitation && !string.IsNullOrEmpty(message?.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
var rawRequest = _gmailService.Users.Messages.Get("me", message.Id);
|
||||
rawRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
|
||||
|
||||
var rawMessage = await rawRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(rawMessage?.Raw))
|
||||
{
|
||||
mimeMessage = rawMessage.GetGmailMimeMessage();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to fetch raw MIME for calendar invitation {MessageId}", message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeMessage != null)
|
||||
{
|
||||
// Raw responses don't include metadata headers. Backfill important fields from MIME.
|
||||
EnrichMailCopyFromMime(baseMailCopy, mimeMessage);
|
||||
}
|
||||
|
||||
await TryMapCalendarInvitationAsync(baseMailCopy, mimeMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var extractedContacts = ExtractContactsFromGmailMessage(message, mimeMessage);
|
||||
|
||||
// Check for local draft mapping using X-Wino-Draft-Id header.
|
||||
@@ -2055,6 +2078,59 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
return packageList;
|
||||
}
|
||||
|
||||
private async Task TryMapCalendarInvitationAsync(MailCopy baseMailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
if (baseMailCopy == null || baseMailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
|
||||
return;
|
||||
|
||||
var invitationUid = mimeMessage.ExtractInvitationUid();
|
||||
if (string.IsNullOrWhiteSpace(invitationUid))
|
||||
return;
|
||||
|
||||
var calendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
|
||||
if (calendars == null || calendars.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var calendar in calendars)
|
||||
{
|
||||
try
|
||||
{
|
||||
var listRequest = _calendarService.Events.List(calendar.RemoteCalendarId);
|
||||
listRequest.ICalUID = invitationUid;
|
||||
listRequest.MaxResults = 1;
|
||||
listRequest.SingleEvents = false;
|
||||
|
||||
var listResponse = await listRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||
var matchedEvent = listResponse?.Items?.FirstOrDefault();
|
||||
if (matchedEvent == null || string.IsNullOrWhiteSpace(matchedEvent.Id))
|
||||
continue;
|
||||
|
||||
await _gmailChangeProcessor.ManageCalendarEventAsync(matchedEvent, calendar, Account).ConfigureAwait(false);
|
||||
|
||||
var localCalendarItem = await _gmailChangeProcessor.GetCalendarItemAsync(calendar.Id, matchedEvent.Id).ConfigureAwait(false);
|
||||
if (localCalendarItem == null)
|
||||
return;
|
||||
|
||||
await _gmailChangeProcessor.UpsertMailInvitationCalendarMappingAsync(new MailInvitationCalendarMapping()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = Account.Id,
|
||||
MailCopyId = baseMailCopy.Id,
|
||||
InvitationUid = invitationUid,
|
||||
CalendarId = calendar.Id,
|
||||
CalendarItemId = localCalendarItem.Id,
|
||||
CalendarRemoteEventId = matchedEvent.Id
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to map Gmail calendar invitation mail {MailCopyId} for calendar {CalendarId}", baseMailCopy.Id, calendar.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Calendar Operations
|
||||
|
||||
@@ -380,8 +380,9 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|
||||
if (!mailExists)
|
||||
{
|
||||
// For drafts, download MIME during initial sync like delta sync.
|
||||
if (folder.SpecialFolderType == SpecialFolderType.Draft)
|
||||
// For drafts and calendar invitations, download MIME during initial sync like delta sync.
|
||||
var itemType = Account.IsCalendarAccessGranted ? message.GetMailItemType() : MailItemType.Mail;
|
||||
if (folder.SpecialFolderType == SpecialFolderType.Draft || itemType == MailItemType.CalendarInvitation)
|
||||
{
|
||||
var draftPackages = await CreateNewMailPackagesAsync(message, folder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -1874,6 +1875,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
// If draft mapping was successful, mailCopy will be null
|
||||
if (mailCopy == null) return null;
|
||||
|
||||
await TryMapCalendarInvitationAsync(mailCopy, mimeMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Outlook messages can only be assigned to 1 folder at a time.
|
||||
// Therefore we don't need to create multiple copies of the same message for different folders.
|
||||
var contacts = ExtractContactsFromOutlookMessage(message);
|
||||
@@ -1882,6 +1885,74 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
return [package];
|
||||
}
|
||||
|
||||
private async Task TryMapCalendarInvitationAsync(MailCopy mailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
|
||||
return;
|
||||
|
||||
var invitationUid = mimeMessage.ExtractInvitationUid();
|
||||
if (string.IsNullOrWhiteSpace(invitationUid))
|
||||
return;
|
||||
|
||||
var calendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
|
||||
if (calendars == null || calendars.Count == 0)
|
||||
return;
|
||||
|
||||
string escapedUid = invitationUid.Replace("'", "''", StringComparison.Ordinal);
|
||||
|
||||
foreach (var calendar in calendars)
|
||||
{
|
||||
try
|
||||
{
|
||||
var eventsResponse = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events
|
||||
.GetAsync(requestConfiguration =>
|
||||
{
|
||||
requestConfiguration.QueryParameters.Filter = $"iCalUId eq '{escapedUid}'";
|
||||
requestConfiguration.QueryParameters.Select = ["id"];
|
||||
requestConfiguration.QueryParameters.Top = 1;
|
||||
}, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var matchedEvent = eventsResponse?.Value?.FirstOrDefault();
|
||||
if (matchedEvent == null || string.IsNullOrWhiteSpace(matchedEvent.Id))
|
||||
continue;
|
||||
|
||||
var fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[matchedEvent.Id]
|
||||
.GetAsync(requestConfiguration =>
|
||||
{
|
||||
requestConfiguration.QueryParameters.Expand = ["attachments($select=id,name,contentType,size,isInline)"];
|
||||
}, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (fullEvent == null)
|
||||
continue;
|
||||
|
||||
await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false);
|
||||
|
||||
var localCalendarItem = await _outlookChangeProcessor.GetCalendarItemAsync(calendar.Id, fullEvent.Id).ConfigureAwait(false);
|
||||
if (localCalendarItem == null)
|
||||
return;
|
||||
|
||||
await _outlookChangeProcessor.UpsertMailInvitationCalendarMappingAsync(new MailInvitationCalendarMapping()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = Account.Id,
|
||||
MailCopyId = mailCopy.Id,
|
||||
InvitationUid = invitationUid,
|
||||
CalendarId = calendar.Id,
|
||||
CalendarItemId = localCalendarItem.Id,
|
||||
CalendarRemoteEventId = fullEvent.Id
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to map Outlook calendar invitation mail {MailCopyId} for calendar {CalendarId}", mailCopy.Id, calendar.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
|
||||
|
||||
Reference in New Issue
Block a user