using System; using System.Linq; using MailKit; using MimeKit; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Extensions; namespace Wino.Services.Extensions; public static class MailkitClientExtensions { public static char MailCopyUidSeparator = '_'; public static uint ResolveUid(string mailCopyId) { var splitted = mailCopyId.Split(MailCopyUidSeparator); if (splitted.Length > 1 && uint.TryParse(splitted[1], out uint parsedUint)) return parsedUint; throw new ArgumentOutOfRangeException(nameof(mailCopyId), mailCopyId, "Invalid mailCopyId format."); } public static UniqueId ResolveUidStruct(string mailCopyId) => new UniqueId(ResolveUid(mailCopyId)); public static string CreateUid(Guid folderId, uint messageUid) => $"{folderId}{MailCopyUidSeparator}{messageUid}"; public static MailImportance GetImportance(this MimeMessage messageSummary) { if (messageSummary.Headers != null && messageSummary.Headers.Contains(HeaderId.Importance)) { var rawImportance = messageSummary.Headers[HeaderId.Importance]; return rawImportance switch { "Low" => MailImportance.Low, "High" => MailImportance.High, _ => MailImportance.Normal, }; } return MailImportance.Normal; } public static bool GetIsRead(this MessageFlags? flags) => flags.GetValueOrDefault().HasFlag(MessageFlags.Seen); public static bool GetIsFlagged(this MessageFlags? flags) => flags.GetValueOrDefault().HasFlag(MessageFlags.Flagged); public static string GetThreadId(this IMessageSummary messageSummary) { // First check whether we have the default values. if (!string.IsNullOrEmpty(messageSummary.ThreadId)) return messageSummary.ThreadId; if (messageSummary.GMailThreadId != null) return messageSummary.GMailThreadId.ToString(); return default; } public static string GetMessageId(this MimeMessage mimeMessage) => MailHeaderExtensions.NormalizeMessageId(mimeMessage.Headers[HeaderId.MessageId]); public static string GetReferences(this MessageIdList messageIdList) => MailHeaderExtensions.JoinStoredReferences(messageIdList); public static string GetInReplyTo(this MimeMessage mimeMessage) { if (mimeMessage.Headers.Contains(HeaderId.InReplyTo)) { return MailHeaderExtensions.NormalizeMessageId(mimeMessage.Headers[HeaderId.InReplyTo]); } return string.Empty; } private static string GetPreviewText(this MimeMessage message) { if (string.IsNullOrEmpty(message.HtmlBody)) return message.TextBody; else return HtmlAgilityPackExtensions.GetPreviewText(message.HtmlBody); } public static MailCopy GetMailDetails(this IMessageSummary messageSummary, MailItemFolder folder, MimeMessage mime = null) { // IMAP UIDs are unique only within a folder. // MailCopy.Id maps to {FolderId}_{UID} for deterministic folder-local identity. var envelope = messageSummary.Envelope; var messageUid = CreateUid(folder.Id, messageSummary.UniqueId.Id); var subject = mime?.Subject ?? envelope?.Subject ?? string.Empty; var previewText = mime != null ? mime.GetPreviewText() : GetPreviewText(messageSummary, subject); // Prefer InternalDate (server received time). Fall back to envelope date and finally UTC now. var creationDate = messageSummary.InternalDate?.UtcDateTime ?? envelope?.Date?.UtcDateTime ?? DateTime.UtcNow; var messageId = MailHeaderExtensions.NormalizeMessageId(mime?.GetMessageId() ?? envelope?.MessageId); var fromName = mime != null ? GetActualSenderName(mime) : GetEnvelopeSenderName(envelope); var fromAddress = mime != null ? GetActualSenderAddress(mime) : GetEnvelopeSenderAddress(envelope); var references = mime?.References?.GetReferences() ?? messageSummary.References?.GetReferences(); var inReplyTo = MailHeaderExtensions.NormalizeMessageId(mime != null ? mime.GetInReplyTo() : envelope?.InReplyTo); var threadId = ResolveThreadId(messageSummary, messageId, references, inReplyTo); var hasAttachments = mime != null ? mime.Attachments.Any() : false; var itemType = mime != null ? GetMailItemTypeFromMime(mime) : MailItemType.Mail; var copy = new MailCopy() { Id = messageUid, CreationDate = creationDate, ThreadId = threadId, MessageId = messageId, Subject = subject, IsRead = messageSummary.Flags.GetIsRead(), IsFlagged = messageSummary.Flags.GetIsFlagged(), PreviewText = previewText, FromAddress = fromAddress, FromName = fromName, IsFocused = false, Importance = mime != null ? mime.GetImportance() : MailImportance.Normal, References = references, InReplyTo = inReplyTo, HasAttachments = hasAttachments, FileId = Guid.NewGuid(), ItemType = itemType }; return copy; } private static string ResolveThreadId(IMessageSummary messageSummary, string messageId, string references, string inReplyTo) { var serverThreadId = messageSummary.GetThreadId(); if (!string.IsNullOrEmpty(serverThreadId)) return serverThreadId; // Fallback threading for IMAP providers that do not expose a native ThreadId: // - Prefer root of References chain // - Then In-Reply-To // - Finally own Message-Id (single-message thread root) var rootReference = references? .Split(new[] { ';', ',', ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) .Select(NormalizeThreadToken) .FirstOrDefault(); if (!string.IsNullOrEmpty(rootReference)) return rootReference; var normalizedInReplyTo = NormalizeThreadToken(inReplyTo); if (!string.IsNullOrEmpty(normalizedInReplyTo)) return normalizedInReplyTo; var normalizedMessageId = NormalizeThreadToken(messageId); if (!string.IsNullOrEmpty(normalizedMessageId)) return normalizedMessageId; return string.Empty; } private static string NormalizeThreadToken(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; value = value.Trim(); if (value.StartsWith("<") && value.EndsWith(">") && value.Length > 2) value = value.Substring(1, value.Length - 2); return value; } private static string GetPreviewText(IMessageSummary messageSummary, string subjectFallback) { if (!string.IsNullOrWhiteSpace(messageSummary.PreviewText)) return messageSummary.PreviewText; return subjectFallback ?? string.Empty; } private static string GetEnvelopeSenderName(Envelope envelope) { var mailbox = envelope?.From?.Mailboxes?.FirstOrDefault() ?? envelope?.Sender?.Mailboxes?.FirstOrDefault(); if (mailbox == null) return Translator.UnknownSender; return string.IsNullOrWhiteSpace(mailbox.Name) ? mailbox.Address : mailbox.Name; } private static string GetEnvelopeSenderAddress(Envelope envelope) { var mailbox = envelope?.From?.Mailboxes?.FirstOrDefault() ?? envelope?.Sender?.Mailboxes?.FirstOrDefault(); return mailbox?.Address ?? Translator.UnknownSender; } /// /// Determines MailItemType based on MIME message content type. /// Calendar invitations have text/calendar content type with METHOD parameter. /// 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() .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. // At some point Wino needs better contact management. public static string GetActualSenderName(MimeMessage message) { if (message == null) return string.Empty; return message.From.Mailboxes.FirstOrDefault()?.Name ?? message.Sender?.Name ?? Translator.UnknownSender; // From MimeKit // The "From" header specifies the author(s) of the message. // If more than one MimeKit.MailboxAddress is added to the list of "From" addresses, // the MimeKit.MimeMessage.Sender should be set to the single MimeKit.MailboxAddress // of the personal actually sending the message. // Also handle: https://stackoverflow.com/questions/46474030/mailkit-from-address //if (message.Sender != null) // return string.IsNullOrEmpty(message.Sender.Name) ? message.Sender.Address : message.Sender.Name; //else if (message.From?.Mailboxes != null) //{ // var firstAvailableName = message.From.Mailboxes.FirstOrDefault(a => !string.IsNullOrEmpty(a.Name))?.Name; // if (string.IsNullOrEmpty(firstAvailableName)) // { // var firstAvailableAddress = message.From.Mailboxes.FirstOrDefault(a => !string.IsNullOrEmpty(a.Address))?.Address; // if (!string.IsNullOrEmpty(firstAvailableAddress)) // { // return firstAvailableAddress; // } // } // return firstAvailableName; //} //// No sender, no from, I don't know what to do. //return Translator.UnknownSender; } // TODO: This is wrong. public static string GetActualSenderAddress(MimeMessage message) { return message.From.Mailboxes.FirstOrDefault()?.Address ?? message.Sender?.Address ?? Translator.UnknownSender; //if (mime == null) // return string.Empty; //bool hasSingleFromMailbox = mime.From.Mailboxes.Count() == 1; //if (hasSingleFromMailbox) // return mime.From.Mailboxes.First().GetAddress(idnEncode: true); //else if (mime.Sender != null) // return mime.Sender.GetAddress(idnEncode: true); //else // return Translator.UnknownSender; } }