如何使用 MimeKit 获取电子邮件消息的 WYSIWYG 正文

How to obtain the WYSIWYG body of an email message using MimeKit

我正在使用一个名为 EAgetmail 的库来检索指定电子邮件的正文并且它运行良好,但是我现在正在使用 Mailkit。问题在于 EAgetmail 相当于用户在电子邮件客户端中看到的 message.body return 正文,但在 mailkit 中它 return 有很多不同的数据。

这是相关代码:

using (var client = new ImapClient())
{
    client.Connect(emailServer, 993, true);
    client.AuthenticationMechanisms.Remove("XOAUTH2");
    client.Authenticate(username, password);
    var inbox = client.Inbox;
    inbox.Open(FolderAccess.ReadOnly);
    SearchQuery query;
    if (checkBox.IsChecked == false)
    {
        query = SearchQuery.DeliveredBefore((DateTime)dateEnd).And(
            SearchQuery.DeliveredAfter((DateTime)dateStart)).And(
            SearchQuery.SubjectContains("Subject to find"));
    }
    else
    {
        query = SearchQuery.SubjectContains("Subject to find");
    }
    foreach (var uid in inbox.Search(query))
    {
        var message = inbox.GetMessage(uid);
        formEmails.Add(message.TextBody);
        messageDate.Add(message.Date.LocalDateTime);
    }
    client.Disconnect(true);
}

我也试过 message.Body.ToString() 并在消息部分中搜索纯文本,但都没有用。 我的问题是如何使用 Mailkit 复制 EAgetmail 的 .body 属性 的效果(return 只有用户看到的纯文本正文内容)?

关于电子邮件的一个常见误解是有一个 well-defined 消息 body 然后是一个附件列表。事实并非如此。实际情况是 MIME 是内容的树结构,很像文件系统。

幸运的是,MIME 确实为邮件客户端应如何解释 MIME 部分的树结构定义了一组通用规则。 Content-Disposition header 旨在向接收客户端提供提示,说明哪些部分应显示为消息的一部分 body,哪些部分应解释为附件。

Content-Disposition header 通常有以下两个值之一:inlineattachment.

这些值的含义应该是相当明显的。如果值为 attachment,则所述 MIME 部分的内容将作为与核心消息分开的文件附件呈现。但是,如果该值为 inline,则该 MIME 部分的内容将内联显示在邮件客户端呈现的核心消息 body 中。如果 Content-Disposition header 不存在,则应将其视为值 inline.

从技术上讲,每个缺少 Content-Disposition header 或标记为 inline 的部分都是核心信息的一部分 body。

不过,还有更多的东西。

现代 MIME 邮件通常包含一个 multipart/alternative MIME 容器,该容器通常包含发件人所写文本的 text/plaintext/html 版本。 text/html 版本的格式通常比 text/plain 版本更接近发件人在其所见即所得编辑器中看到的内容。

以两种格式发送消息文本的原因是并非所有邮件客户端都能够显示 HTML。

接收客户端应仅显示 multipart/alternative 容器中包含的备选视图之一。由于备选视图按照发送者在他或她的所见即所得编辑器中看到的内容从最不忠实到最忠实的顺序列出,因此接收客户端应该从末尾开始遍历备选视图列表并向后工作,直到找到它认为的部分能够显示。

示例:

multipart/alternative
  text/plain
  text/html

如上例所示,text/html 部分列在最后,因为它最忠实于发件人在编写邮件时在其所见即所得编辑器中看到的内容。

为了使事情变得更加复杂,有时现代邮件客户端会使用 multipart/related MIME 容器而不是简单的 text/html 部分,以便在 [=94] 中嵌入图像和其他多媒体内容=].

示例:

multipart/alternative
  text/plain
  multipart/related
    text/html
    image/jpeg
    video/mp4
    image/png

在上面的示例中,备选视图之一是 multipart/related 容器,其中包含 HTML 版本的消息 body,它引用了同级视频和图像。

既然您已经大致了解消息的结构以及如何解释各种 MIME 实体,我们就可以开始弄清楚如何按预期实际呈现消息。

使用 MimeVisitor(呈现消息的最准确方法)

MimeKit 包含一个 MimeVisitor class 用于访问 MIME 树结构中的每个节点。例如,以下 MimeVisitor subclass 可用于生成 HTML 以由浏览器控件(例如 WebBrowser)呈现:

/// <summary>
/// Visits a MimeMessage and generates HTML suitable to be rendered by a browser control.
/// </summary>
class HtmlPreviewVisitor : MimeVisitor
{
    List<MultipartRelated> stack = new List<MultipartRelated> ();
    List<MimeEntity> attachments = new List<MimeEntity> ();
    readonly string tempDir;
    string body;

    /// <summary>
    /// Creates a new HtmlPreviewVisitor.
    /// </summary>
    /// <param name="tempDirectory">A temporary directory used for storing image files.</param>
    public HtmlPreviewVisitor (string tempDirectory)
    {
        tempDir = tempDirectory;
    }

    /// <summary>
    /// The list of attachments that were in the MimeMessage.
    /// </summary>
    public IList<MimeEntity> Attachments {
        get { return attachments; }
    }

    /// <summary>
    /// The HTML string that can be set on the BrowserControl.
    /// </summary>
    public string HtmlBody {
        get { return body ?? string.Empty; }
    }

    protected override void VisitMultipartAlternative (MultipartAlternative alternative)
    {
        // walk the multipart/alternative children backwards from greatest level of faithfulness to the least faithful
        for (int i = alternative.Count - 1; i >= 0 && body == null; i--)
            alternative[i].Accept (this);
    }

    protected override void VisitMultipartRelated (MultipartRelated related)
    {
        var root = related.Root;

        // push this multipart/related onto our stack
        stack.Add (related);

        // visit the root document
        root.Accept (this);

        // pop this multipart/related off our stack
        stack.RemoveAt (stack.Count - 1);
    }

    // look up the image based on the img src url within our multipart/related stack
    bool TryGetImage (string url, out MimePart image)
    {
        UriKind kind;
        int index;
        Uri uri;

        if (Uri.IsWellFormedUriString (url, UriKind.Absolute))
            kind = UriKind.Absolute;
        else if (Uri.IsWellFormedUriString (url, UriKind.Relative))
            kind = UriKind.Relative;
        else
            kind = UriKind.RelativeOrAbsolute;

        try {
            uri = new Uri (url, kind);
        } catch {
            image = null;
            return false;
        }

        for (int i = stack.Count - 1; i >= 0; i--) {
            if ((index = stack[i].IndexOf (uri)) == -1)
                continue;

            image = stack[i][index] as MimePart;
            return image != null;
        }

        image = null;

        return false;
    }

    // Save the image to our temp directory and return a "file://" url suitable for
    // the browser control to load.
    // Note: if you'd rather embed the image data into the HTML, you can construct a
    // "data:" url instead.
    string SaveImage (MimePart image, string url)
    {
        string fileName = url.Replace (':', '_').Replace ('\', '_').Replace ('/', '_');

        string path = Path.Combine (tempDir, fileName);

        if (!File.Exists (path)) {
            using (var output = File.Create (path))
                image.ContentObject.DecodeTo (output);
        }

        return "file://" + path.Replace ('\', '/');
    }

    // Replaces <img src=...> urls that refer to images embedded within the message with
    // "file://" urls that the browser control will actually be able to load.
    void HtmlTagCallback (HtmlTagContext ctx, HtmlWriter htmlWriter)
    {
        if (ctx.TagId == HtmlTagId.Image && !ctx.IsEndTag && stack.Count > 0) {
            ctx.WriteTag (htmlWriter, false);

            // replace the src attribute with a file:// URL
            foreach (var attribute in ctx.Attributes) {
                if (attribute.Id == HtmlAttributeId.Src) {
                    MimePart image;
                    string url;

                    if (!TryGetImage (attribute.Value, out image)) {
                        htmlWriter.WriteAttribute (attribute);
                        continue;
                    }

                    url = SaveImage (image, attribute.Value);

                    htmlWriter.WriteAttributeName (attribute.Name);
                    htmlWriter.WriteAttributeValue (url);
                } else {
                    htmlWriter.WriteAttribute (attribute);
                }
            }
        } else if (ctx.TagId == HtmlTagId.Body && !ctx.IsEndTag) {
            ctx.WriteTag (htmlWriter, false);

            // add and/or replace oncontextmenu="return false;"
            foreach (var attribute in ctx.Attributes) {
                if (attribute.Name.ToLowerInvariant () == "oncontextmenu")
                    continue;

                htmlWriter.WriteAttribute (attribute);
            }

            htmlWriter.WriteAttribute ("oncontextmenu", "return false;");
        } else {
            // pass the tag through to the output
            ctx.WriteTag (htmlWriter, true);
        }
    }

    protected override void VisitTextPart (TextPart entity)
    {
        TextConverter converter;

        if (body != null) {
            // since we've already found the body, treat this as an attachment
            attachments.Add (entity);
            return;
        }

        if (entity.IsHtml) {
            converter = new HtmlToHtml {
                HtmlTagCallback = HtmlTagCallback
            };
        } else if (entity.IsFlowed) {
            var flowed = new FlowedToHtml ();
            string delsp;

            if (entity.ContentType.Parameters.TryGetValue ("delsp", out delsp))
                flowed.DeleteSpace = delsp.ToLowerInvariant () == "yes";

            converter = flowed;
        } else {
            converter = new TextToHtml ();
        }

        body = converter.Convert (entity.Text);
    }

    protected override void VisitTnefPart (TnefPart entity)
    {
        // extract any attachments in the MS-TNEF part
        attachments.AddRange (entity.ExtractAttachments ());
    }

    protected override void VisitMessagePart (MessagePart entity)
    {
        // treat message/rfc822 parts as attachments
        attachments.Add (entity);
    }

    protected override void VisitMimePart (MimePart entity)
    {
        // realistically, if we've gotten this far, then we can treat this as an attachment
        // even if the IsAttachment property is false.
        attachments.Add (entity);
    }
}

您使用此访问者的方式可能如下所示:

void Render (MimeMessage message)
{
    var tmpDir = Path.Combine (Path.GetTempPath (), message.MessageId);
    var visitor = new HtmlPreviewVisitor (tmpDir);

    Directory.CreateDirectory (tmpDir);

    message.Accept (visitor);

    DisplayHtml (visitor.HtmlBody);
    DisplayAttachments (visitor.Attachments);
}

使用 TextBodyHtmlBody 属性(最简单的方法)

为了简化获取消息文本的常见任务,MimeMessage 包含两个属性可以帮助您获取 text/plaintext/html 版本的消息 body.它们分别是 TextBodyHtmlBody

但是请记住,至少对于 HtmlBody 属性,HTML 部分可能是 [=31] 的 child =],允许它引用图像和其他类型的媒体,这些媒体也包含在该 multipart/related 实体中。这个属性实际上只是一个方便属性,并不能很好地替代您自己遍历MIME结构以便您可以正确解释相关内容。

旧 post,但相关,可以使用内置 MimeKit 将正文获取为文本:

string body = mimeMessage.GetTextBody(MimeKit.Text.TextFormat.Plain);