303 lines
12 KiB
C#
303 lines
12 KiB
C#
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(),
|
|
IsReadReceiptRequested = mime?.HasReadReceiptRequest()
|
|
?? (messageSummary.Headers?.Contains(Constants.DispositionNotificationToHeader) == true
|
|
&& !string.IsNullOrWhiteSpace(messageSummary.Headers[Constants.DispositionNotificationToHeader])),
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines MailItemType based on MIME message content type.
|
|
/// Calendar invitations have text/calendar content type with METHOD parameter.
|
|
/// </summary>
|
|
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<MimePart>()
|
|
.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;
|
|
}
|
|
}
|