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 /// /// Extracts all attachments (inline and regular) from a MimeMessage /// and returns them as Graph SDK FileAttachment objects. /// public static List ExtractAttachments(this MimeMessage mime) { var attachments = new List(); 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 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 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(); 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 }