Files
Wino-Mail/Wino.Core/Extensions/OutlookIntegratorExtensions.cs
2026-04-11 21:02:51 +02:00

507 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.Graph.Models;
using MimeKit;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Extensions;
using Wino.Core.Misc;
namespace Wino.Core.Extensions;
public static class OutlookIntegratorExtensions
{
public static MailItemFolder GetLocalFolder(this MailFolder nativeFolder, Guid accountId)
{
return new MailItemFolder()
{
Id = Guid.NewGuid(),
FolderName = nativeFolder.DisplayName,
RemoteFolderId = nativeFolder.Id,
ParentRemoteFolderId = nativeFolder.ParentFolderId,
IsSynchronizationEnabled = true,
MailAccountId = accountId,
IsHidden = nativeFolder.IsHidden.GetValueOrDefault()
};
}
public static bool GetIsDraft(this Message message)
=> message != null && message.IsDraft.GetValueOrDefault();
public static bool GetIsRead(this Message message)
=> message != null && message.IsRead.GetValueOrDefault();
public static bool GetIsFocused(this Message message)
=> message?.InferenceClassification != null && message.InferenceClassification.Value == InferenceClassificationType.Focused;
public static bool GetIsFlagged(this Message message)
=> message?.Flag?.FlagStatus != null && message.Flag.FlagStatus == FollowupFlagStatus.Flagged;
public static bool GetIsReadReceiptRequested(this Message message)
=> message?.IsReadReceiptRequested.GetValueOrDefault() == true
|| message?.InternetMessageHeaders?.Any(h =>
string.Equals(h.Name, Domain.Constants.DispositionNotificationToHeader, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(h.Value)) == true;
public static MailCopy AsMailCopy(this Message outlookMessage)
{
bool isDraft = GetIsDraft(outlookMessage);
var mailCopy = new MailCopy()
{
MessageId = MailHeaderExtensions.NormalizeMessageId(outlookMessage.InternetMessageId),
IsFlagged = GetIsFlagged(outlookMessage),
IsFocused = GetIsFocused(outlookMessage),
Importance = !outlookMessage.Importance.HasValue ? MailImportance.Normal : (MailImportance)outlookMessage.Importance.Value,
IsRead = GetIsRead(outlookMessage),
IsReadReceiptRequested = GetIsReadReceiptRequested(outlookMessage),
IsDraft = isDraft,
CreationDate = outlookMessage.ReceivedDateTime.GetValueOrDefault().DateTime,
HasAttachments = outlookMessage.HasAttachments.GetValueOrDefault(),
PreviewText = outlookMessage.BodyPreview,
Id = outlookMessage.Id,
ThreadId = outlookMessage.ConversationId,
FromName = outlookMessage.From?.EmailAddress?.Name,
FromAddress = outlookMessage.From?.EmailAddress?.Address,
Subject = outlookMessage.Subject,
FileId = Guid.NewGuid(),
ItemType = MailItemType.Mail // ItemType will be set by caller if calendar access is granted
};
// Extract In-Reply-To and References from InternetMessageHeaders for threading.
if (outlookMessage.InternetMessageHeaders != null)
{
var inReplyToHeader = outlookMessage.InternetMessageHeaders
.FirstOrDefault(h => string.Equals(h.Name, "In-Reply-To", StringComparison.OrdinalIgnoreCase));
if (inReplyToHeader != null)
mailCopy.InReplyTo = MailHeaderExtensions.StripAngleBrackets(inReplyToHeader.Value);
var referencesHeader = outlookMessage.InternetMessageHeaders
.FirstOrDefault(h => string.Equals(h.Name, "References", StringComparison.OrdinalIgnoreCase));
if (referencesHeader != null)
mailCopy.References = MailHeaderExtensions.NormalizeReferences(referencesHeader.Value);
}
if (mailCopy.IsDraft)
mailCopy.DraftId = mailCopy.ThreadId;
return mailCopy;
}
public static MailItemType GetMailItemType(this Message message)
{
// Check if the message is an EventMessage (calendar-related)
if (message is EventMessage eventMessage)
{
// Try to get MeetingMessageType from the property
if (eventMessage.MeetingMessageType.HasValue)
{
return eventMessage.MeetingMessageType.Value switch
{
MeetingMessageType.MeetingRequest => MailItemType.CalendarInvitation,
MeetingMessageType.MeetingCancelled => MailItemType.CalendarCancellation,
MeetingMessageType.MeetingAccepted or
MeetingMessageType.MeetingTenativelyAccepted or
MeetingMessageType.MeetingDeclined => MailItemType.CalendarResponse,
_ => MailItemType.Mail
};
}
// Fallback: Check @odata.type in AdditionalData to determine specific type
if (message.AdditionalData?.TryGetValue("@odata.type", out var odataType) == true)
{
var odataTypeString = odataType?.ToString();
if (odataTypeString != null)
{
// eventMessageRequest -> CalendarInvitation
if (odataTypeString.Contains("eventMessageRequest", StringComparison.OrdinalIgnoreCase))
return MailItemType.CalendarInvitation;
// eventMessageResponse -> CalendarResponse
if (odataTypeString.Contains("eventMessageResponse", StringComparison.OrdinalIgnoreCase))
return MailItemType.CalendarResponse;
// Generic eventMessage without specific type - assume invitation
if (odataTypeString.Contains("eventMessage", StringComparison.OrdinalIgnoreCase))
return MailItemType.CalendarInvitation;
}
}
return MailItemType.CalendarInvitation;
}
return MailItemType.Mail;
}
public static Message AsOutlookMessage(this MimeMessage mime, bool includeInternetHeaders, string conversationId = null)
{
var fromAddress = GetRecipients(mime.From).ElementAt(0);
var toAddresses = GetRecipients(mime.To).ToList();
var ccAddresses = GetRecipients(mime.Cc).ToList();
var bccAddresses = GetRecipients(mime.Bcc).ToList();
var replyToAddresses = GetRecipients(mime.ReplyTo).ToList();
// Prefer HTML body, fall back to plain text.
var (bodyContent, bodyType) = mime.HtmlBody != null
? (mime.HtmlBody, BodyType.Html)
: (mime.TextBody ?? string.Empty, BodyType.Text);
var message = new Message()
{
Subject = mime.Subject,
Importance = GetImportance(mime.Importance),
Body = new ItemBody() { ContentType = bodyType, Content = bodyContent },
IsDraft = false,
IsRead = true,
ToRecipients = toAddresses,
CcRecipients = ccAddresses,
BccRecipients = bccAddresses,
From = fromAddress,
InternetMessageId = MailHeaderExtensions.ToHeaderMessageId(mime.MessageId),
IsReadReceiptRequested = mime.HasReadReceiptRequest(),
ReplyTo = replyToAddresses,
};
if (!string.IsNullOrEmpty(conversationId))
{
message.ConversationId = conversationId;
}
// Headers are only included when creating the draft.
// Graph API throws an error if headers are included in send/patch operations.
if (includeInternetHeaders)
{
message.InternetMessageHeaders = GetHeaderList(mime);
}
return message;
}
public static AccountCalendar AsCalendar(this Calendar outlookCalendar, MailAccount assignedAccount, string fallbackBackgroundColor = null)
{
var calendar = new AccountCalendar()
{
AccountId = assignedAccount.Id,
Id = Guid.NewGuid(),
RemoteCalendarId = outlookCalendar.Id,
IsPrimary = outlookCalendar.IsDefaultCalendar.GetValueOrDefault(),
Name = outlookCalendar.Name,
IsSynchronizationEnabled = true,
IsExtended = true,
};
// Colors:
// Bg must be present. Generate flat one if doesn't exists.
// Text doesnt exists for Outlook.
calendar.BackgroundColorHex = fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex();
calendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(calendar.BackgroundColorHex);
return calendar;
}
private static string GetRfc5545DayOfWeek(DayOfWeekObject dayOfWeek)
{
return dayOfWeek switch
{
DayOfWeekObject.Monday => "MO",
DayOfWeekObject.Tuesday => "TU",
DayOfWeekObject.Wednesday => "WE",
DayOfWeekObject.Thursday => "TH",
DayOfWeekObject.Friday => "FR",
DayOfWeekObject.Saturday => "SA",
DayOfWeekObject.Sunday => "SU",
_ => throw new ArgumentOutOfRangeException(nameof(dayOfWeek), dayOfWeek, null)
};
}
public static string ToRfc5545RecurrenceString(this PatternedRecurrence recurrence)
{
if (recurrence == null || recurrence.Pattern == null)
throw new ArgumentNullException(nameof(recurrence), "PatternedRecurrence or its Pattern cannot be null.");
var ruleBuilder = new StringBuilder("RRULE:");
var pattern = recurrence.Pattern;
// Frequency
switch (pattern.Type)
{
case RecurrencePatternType.Daily:
ruleBuilder.Append("FREQ=DAILY;");
break;
case RecurrencePatternType.Weekly:
ruleBuilder.Append("FREQ=WEEKLY;");
break;
case RecurrencePatternType.AbsoluteMonthly:
ruleBuilder.Append("FREQ=MONTHLY;");
break;
case RecurrencePatternType.AbsoluteYearly:
ruleBuilder.Append("FREQ=YEARLY;");
break;
case RecurrencePatternType.RelativeMonthly:
ruleBuilder.Append("FREQ=MONTHLY;");
break;
case RecurrencePatternType.RelativeYearly:
ruleBuilder.Append("FREQ=YEARLY;");
break;
default:
throw new NotSupportedException($"Unsupported recurrence pattern type: {pattern.Type}");
}
// Interval
if (pattern.Interval > 0)
ruleBuilder.Append($"INTERVAL={pattern.Interval};");
// Days of Week
if (pattern.DaysOfWeek?.Any() == true)
{
var days = string.Join(",", pattern.DaysOfWeek.Select(day => day.ToString().ToUpperInvariant().Substring(0, 2)));
ruleBuilder.Append($"BYDAY={days};");
}
// Day of Month (BYMONTHDAY)
if (pattern.Type == RecurrencePatternType.AbsoluteMonthly || pattern.Type == RecurrencePatternType.AbsoluteYearly)
{
if (pattern.DayOfMonth <= 0)
throw new ArgumentException("DayOfMonth must be greater than 0 for absoluteMonthly or absoluteYearly patterns.");
ruleBuilder.Append($"BYMONTHDAY={pattern.DayOfMonth};");
}
// Month (BYMONTH)
if (pattern.Type == RecurrencePatternType.AbsoluteYearly || pattern.Type == RecurrencePatternType.RelativeYearly)
{
if (pattern.Month <= 0)
throw new ArgumentException("Month must be greater than 0 for absoluteYearly or relativeYearly patterns.");
ruleBuilder.Append($"BYMONTH={pattern.Month};");
}
// Count or Until
if (recurrence.Range != null)
{
if (recurrence.Range.Type == RecurrenceRangeType.EndDate && recurrence.Range.EndDate != null)
{
// RFC 5545 requires YYYYMMDD or YYYYMMDDTHHMMSSinvalid format (no dashes or colons)
var untilDate = recurrence.Range.EndDate.Value.DateTime.ToString("yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
ruleBuilder.Append($"UNTIL={untilDate};");
}
else if (recurrence.Range.Type == RecurrenceRangeType.Numbered && recurrence.Range.NumberOfOccurrences.HasValue)
{
ruleBuilder.Append($"COUNT={recurrence.Range.NumberOfOccurrences.Value};");
}
}
// Remove trailing semicolon
return ruleBuilder.ToString().TrimEnd(';');
}
public static DateTimeOffset GetDateTimeOffsetFromDateTimeTimeZone(DateTimeTimeZone dateTimeTimeZone)
{
if (dateTimeTimeZone == null || string.IsNullOrEmpty(dateTimeTimeZone.DateTime))
{
throw new ArgumentException("DateTimeTimeZone or DateTime is null or empty.");
}
try
{
// Parse the DateTime string
if (!DateTime.TryParse(dateTimeTimeZone.DateTime, out DateTime parsedDateTime))
{
throw new ArgumentException("DateTime string is not in a valid format.");
}
// If no timezone is provided, assume UTC
if (string.IsNullOrEmpty(dateTimeTimeZone.TimeZone))
{
return new DateTimeOffset(parsedDateTime, TimeSpan.Zero);
}
try
{
// Get TimeZoneInfo to get the offset
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(dateTimeTimeZone.TimeZone);
TimeSpan offset = timeZoneInfo.GetUtcOffset(parsedDateTime);
return new DateTimeOffset(parsedDateTime, offset);
}
catch (TimeZoneNotFoundException)
{
// If timezone is not found, assume UTC as fallback
return new DateTimeOffset(parsedDateTime, TimeSpan.Zero);
}
}
catch (Exception)
{
throw;
}
}
public static DateTime GetLocalDateTimeFromDateTimeTimeZone(DateTimeTimeZone dateTimeTimeZone)
{
if (dateTimeTimeZone == null || string.IsNullOrEmpty(dateTimeTimeZone.DateTime))
{
throw new ArgumentException("DateTimeTimeZone or DateTime is null or empty.");
}
if (!DateTime.TryParse(dateTimeTimeZone.DateTime, out DateTime parsedDateTime))
{
throw new ArgumentException("DateTime string is not in a valid format.");
}
return DateTime.SpecifyKind(parsedDateTime, DateTimeKind.Unspecified);
}
private static AttendeeStatus GetAttendeeStatus(ResponseType? responseType)
{
return responseType switch
{
ResponseType.None => AttendeeStatus.NeedsAction,
ResponseType.NotResponded => AttendeeStatus.NeedsAction,
ResponseType.Organizer => AttendeeStatus.Accepted,
ResponseType.TentativelyAccepted => AttendeeStatus.Tentative,
ResponseType.Accepted => AttendeeStatus.Accepted,
ResponseType.Declined => AttendeeStatus.Declined,
_ => AttendeeStatus.NeedsAction
};
}
public static CalendarEventAttendee CreateAttendee(this Attendee attendee, Guid calendarItemId, string organizerEmail = null)
{
// Check if this attendee is the organizer by comparing email addresses
bool isOrganizer = !string.IsNullOrEmpty(organizerEmail) &&
!string.IsNullOrEmpty(attendee?.EmailAddress?.Address) &&
string.Equals(attendee.EmailAddress.Address, organizerEmail, StringComparison.OrdinalIgnoreCase);
var eventAttendee = new CalendarEventAttendee()
{
CalendarItemId = calendarItemId,
Id = Guid.NewGuid(),
Email = attendee.EmailAddress?.Address,
Name = attendee.EmailAddress?.Name,
AttendenceStatus = GetAttendeeStatus(attendee.Status.Response),
IsOrganizer = isOrganizer,
IsOptionalAttendee = attendee.Type == AttendeeType.Optional,
};
return eventAttendee;
}
#region Mime to Outlook Message Helpers
/// <summary>
/// Extracts all attachments (inline and regular) from a MimeMessage
/// and returns them as Graph SDK FileAttachment objects.
/// </summary>
public static List<FileAttachment> ExtractAttachments(this MimeMessage mime)
{
var attachments = new List<FileAttachment>();
foreach (var part in mime.BodyParts)
{
bool isInline = part.ContentDisposition?.Disposition == "inline";
if (!part.IsAttachment && !isInline)
continue;
if (part is not MimePart mimePart || mimePart.Content == null)
continue;
using var memory = new MemoryStream();
mimePart.Content.DecodeTo(memory);
attachments.Add(new FileAttachment()
{
Name = part.ContentDisposition?.FileName ?? part.ContentType.Name,
ContentBytes = memory.ToArray(),
ContentType = part.ContentType.MimeType,
ContentId = part.ContentId,
IsInline = isInline
});
}
return attachments;
}
private static IEnumerable<Recipient> GetRecipients(this InternetAddressList internetAddresses)
{
foreach (var address in internetAddresses)
{
if (address is MailboxAddress mailboxAddress)
yield return new Recipient() { EmailAddress = new EmailAddress() { Address = mailboxAddress.Address, Name = mailboxAddress.Name } };
else if (address is GroupAddress groupAddress)
{
// TODO: Group addresses are not directly supported.
// It'll be individually added.
foreach (var mailbox in groupAddress.Members)
if (mailbox is MailboxAddress groupMemberMailAddress)
yield return new Recipient() { EmailAddress = new EmailAddress() { Address = groupMemberMailAddress.Address, Name = groupMemberMailAddress.Name } };
}
}
}
private static Importance? GetImportance(MessageImportance importance)
{
return importance switch
{
MessageImportance.Low => Importance.Low,
MessageImportance.Normal => Importance.Normal,
MessageImportance.High => Importance.High,
_ => null
};
}
private static List<InternetMessageHeader> GetHeaderList(this MimeMessage mime)
{
// Graph API only allows max of 5 headers.
// Graph only allows setting custom internet headers (typically X-*).
// Reply/threading headers like In-Reply-To and References are managed by
// createReply/createReplyAll flows and must not be sent here.
const int headerLimit = 5;
string[] headersToIgnore = ["Date", "To", "Cc", "Bcc", "MIME-Version", "From", "Subject", "Message-Id"];
var headers = new List<InternetMessageHeader>();
void AddHeader(string name, string value)
{
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) return;
if (headers.Count >= headerLimit) return;
if (headers.Any(h => string.Equals(h.Name, name, StringComparison.OrdinalIgnoreCase))) return;
// No header value should exceed 995 characters.
var headerValue = value.Length >= 995 ? value.Substring(0, 995) : value;
headers.Add(new InternetMessageHeader() { Name = name, Value = headerValue });
}
// PRIORITY: Always include WinoLocalDraftHeader first if it exists.
var winoDraftHeader = mime.Headers.FirstOrDefault(h => h.Field == Domain.Constants.WinoLocalDraftHeader);
if (winoDraftHeader != null)
AddHeader(winoDraftHeader.Field, winoDraftHeader.Value);
// Fill remaining slots with custom headers only (avoid Graph restrictions).
foreach (var header in mime.Headers)
{
if (headers.Count >= headerLimit) break;
if (header.Field == Domain.Constants.WinoLocalDraftHeader) continue;
if (headersToIgnore.Contains(header.Field)) continue;
// Only include custom headers beyond the core threading ones.
if (!header.Field.StartsWith("X-", StringComparison.OrdinalIgnoreCase)) continue;
AddHeader(header.Field, header.Value);
}
return headers;
}
#endregion
}