MimeKit.MimeMessage to Browser-Renderable HTML
Asked Answered
U

1

9

Is there a way to convert a MimeKit.MimeMessage to HTML that can be rendered in a web browser? I'm not concerned with message attachments, but would like to be able to display the message body, complete with embedded images, in a browser. I'm new to MimeKit and couldn't locate anything in the API docs for this. Any info is appreciated.

EDIT: I didn't find a way to do this natively with MimeKit, but I combined it with the HtmlAgilityPack to parse the MimeMessage.HtmBody and fix the inline images. This seems to work and I'll go with it unless someone has a better idea. For reference, here's the code:

//////////////////////////////////////////////////////////////////////////////////////////
// use MimeKit to parse the message
//////////////////////////////////////////////////////////////////////////////////////////
MimeKit.MimeMessage msg = MimeKit.MimeMessage.Load(stream);

//////////////////////////////////////////////////////////////////////////////////////////
// use HtmlAgilityPack to parse the resulting html in order to fix inline images
//////////////////////////////////////////////////////////////////////////////////////////
HtmlAgilityPack.HtmlDocument hdoc = new HtmlAgilityPack.HtmlDocument();
hdoc.LoadHtml(msg.HtmlBody);
// find all image nodes
var images = hdoc.DocumentNode.Descendants("img");
foreach (var img in images)
{                        
    // check that this is an inline image
    string cid = img.Attributes["src"].Value;
    if (cid.StartsWith("cid:"))
    {
        // remove the cid part of the attribute
        cid = cid.Remove(0, 4);
        // find image object in MimeMessage
        MimeKit.MimePart part = msg.BodyParts.First(x => x.ContentId == cid) as MimeKit.MimePart;
        if (part != null)
        {
            using (MemoryStream mstream = new MemoryStream())
            {
                // get the raw image content
                part.ContentObject.WriteTo(mstream);
                mstream.Flush();
                byte[] imgbytes = mstream.ToArray();
                // fix the image source by making it an embedded image
                img.Attributes["src"].Value = "data:" + part.ContentType.MimeType + ";" + part.ContentTransferEncoding.ToString().ToLower() + "," +
                    System.Text.ASCIIEncoding.ASCII.GetString(imgbytes);
            }
        }
    }
}

// write the resulting html to the output stream
hdoc.Save(outputStream);
Uprush answered 14/7, 2015 at 21:42 Comment(0)
B
14

Your solution is similar to the logic I used to use in MimeKit's MessageReader sample, but now MimeKit provides a better solution:

/// <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 ();
        }

        string text = entity.Text;

        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);
    }
}

And then to use this custom HtmlPreviewVisitor class, you'd have a method something like this:

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

    Directory.CreateDirectory (tmpDir);

    message.Accept (visitor);

    browser.DocumentText = visitor.HtmlBody;
}

I know that this seems like a lot of code, but it's covering a lot more than just the simple cases. You'll notice that it also handles rendering text/plain as well as text/plain; format=flowed bodies if the HTML is not available. It also correctly only uses images that are part of the encapsulating multipart/related tree.

One way you could modify this code is to embed the images into the img tags instead of using a temp directory. To do that, you'd modify the SaveImage method to be something like this (be warned, this next segment of code is untested):

string SaveImage (MimePart image, string url)
{
    using (var output = new MemoryStream ()) {
        image.ContentObject.DecodeTo (output);

        var buffer = output.GetBuffer ();
        int length = (int) output.Length;

        return string.Format ("data:{0};base64,{1}", image.ContentType.MimeType, Convert.ToBase64String (buffer, 0, length));
    }
}
Back answered 15/7, 2015 at 16:9 Comment(7)
Looks like I was basically on the right track, but your solution is much more comprehensive as you indicated. This helps a lot. Thanks.Uprush
No problem, and yes, you were definitely on the right track :-)Back
Great solution! The experimental SaveImage is working in a web environment with Angular using .Net 4.5.2.Maupin
@Back how does this compare to MimeMessage.HtmlBody? And if this is more comprehensive so whyd don't make it native into the library?Peppercorn
MimeMessage.HtmlBody just returns a string that contains the content of the text/html body part, but if the message has embedded images that the HTML references via a cid: URL, then setting the HtmlBody string on your BrowserControl won't be able to find those images. HtmlPreviewVisitor is not part of the library because every mail client app that wants to render a message might want to render the message differently. Outlook renders messages different than Thunderbird which renders messages different than GMail, etc. HtmlPreviewVisitor is really just a starting point.Back
iam trying to use the above provided RENDER method but it generates the error "Error CS0246 The type or namespace name 'HtmlPreviewVisitor' could not be found (are you missing a using directive or an assembly reference?)", what am I missing ?Misunderstanding
You need to copy the HtmlPreviewVisitor class too.Back

© 2022 - 2024 — McMap. All rights reserved.