using System; using System.Collections.Generic; using System.IO; using MimeKit; using MimeKit.Cryptography; using MimeKit.Text; using MimeKit.Tnef; namespace Wino.Core.Domain.Models.MailItem; /// /// Visits a MimeMessage and generates HTML suitable to be rendered by a browser control. /// public class HtmlPreviewVisitor : MimeVisitor { private static readonly HashSet BlockedTags = new(StringComparer.OrdinalIgnoreCase) { "script", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "meta", "form", "link" }; private static readonly HashSet AllowedDataImageMimeTypes = new(StringComparer.OrdinalIgnoreCase) { "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/bmp", "image/x-icon", "image/avif", "image/svg+xml" }; private readonly List stack = []; private readonly List attachments = []; readonly string tempDir; public string Body { get; set; } public Dictionary Signatures = []; /// /// Creates a new HtmlPreviewVisitor. /// /// A temporary directory used for storing image files. public HtmlPreviewVisitor(string tempDirectory) { tempDir = tempDirectory; } /// /// The list of attachments that were in the MimeMessage. /// public IList Attachments { get { return attachments; } } /// /// The HTML string that can be set on the BrowserControl. /// public string HtmlBody { get { return Body ?? string.Empty; } } protected override void VisitMultipartAlternative(MultipartAlternative alternative) { // Prefer rich body alternatives first, and only fall back to calendar text if nothing else exists. for (int i = alternative.Count - 1; i >= 0 && Body == null; i--) { if (IsCalendarText(alternative[i])) continue; alternative[i].Accept(this); } for (int i = alternative.Count - 1; i >= 0 && Body == null; i--) { if (!IsCalendarText(alternative[i])) continue; 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); } protected override void VisitMultipartSigned(MultipartSigned signed) { VerifySignatures(signed.Verify()); VisitMultipart(signed); } // look up the image based on the img src url within our multipart/related stack bool TryGetImage(string url, out MimePart image) { image = null; if (string.IsNullOrWhiteSpace(url)) return false; UriKind kind; int index; Uri uri = null; 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 { // noop: we still attempt CID/content-id lookup below. } for (int i = stack.Count - 1; i >= 0; i--) { if (uri != null && (index = stack[i].IndexOf(uri)) != -1) { image = stack[i][index] as MimePart; if (image != null) return true; } var normalizedContentId = NormalizeContentId(url); if (string.IsNullOrEmpty(normalizedContentId)) continue; foreach (var relatedPart in stack[i]) { if (relatedPart is not MimePart candidate || string.IsNullOrEmpty(candidate.ContentId)) continue; if (string.Equals(candidate.ContentId.Trim('<', '>'), normalizedContentId, StringComparison.OrdinalIgnoreCase)) { image = candidate; return true; } } } return false; } private static string NormalizeContentId(string url) { var trimmed = url.Trim().Trim('\'', '"', '<', '>'); if (trimmed.StartsWith("cid:", StringComparison.OrdinalIgnoreCase)) trimmed = trimmed[4..]; return trimmed.Trim('<', '>'); } // 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) { using (var memory = new MemoryStream()) { image.Content.DecodeTo(memory); var buffer = memory.GetBuffer(); var length = (int)memory.Length; var base64 = Convert.ToBase64String(buffer, 0, length); return string.Format("data:{0};base64,{1}", image.ContentType.MimeType, base64); } } // Replaces image references that refer to images embedded within the message with // "data:" urls the browser control can load. Also sanitizes dangerous tags/attributes. void HtmlTagCallback(HtmlTagContext ctx, HtmlWriter htmlWriter) { var tagName = ctx.TagName; if (BlockedTags.Contains(tagName)) { ctx.DeleteTag = true; ctx.DeleteEndTag = true; return; } if (ctx.IsEndTag) { ctx.WriteTag(htmlWriter, true); return; } ctx.WriteTag(htmlWriter, false); foreach (var attribute in ctx.Attributes) { var attributeName = attribute.Name; if (ShouldDropAttribute(tagName, attributeName)) continue; if (TryResolveImageAttribute(tagName, attributeName, attribute.Value, out var resolvedValue)) { htmlWriter.WriteAttributeName(attributeName); htmlWriter.WriteAttributeValue(resolvedValue); continue; } if (IsUrlAttribute(attributeName)) { if (!TrySanitizeUrlValue(attribute.Value, out var sanitizedUrl)) continue; htmlWriter.WriteAttributeName(attributeName); htmlWriter.WriteAttributeValue(sanitizedUrl); continue; } htmlWriter.WriteAttribute(attribute); } if (ctx.TagId == HtmlTagId.Body) htmlWriter.WriteAttribute("oncontextmenu", "return false;"); } private bool TryResolveImageAttribute(string tagName, string attributeName, string value, out string resolvedValue) { resolvedValue = null; if (string.IsNullOrWhiteSpace(value)) return false; var lowerAttributeName = attributeName.ToLowerInvariant(); var isImageTag = string.Equals(tagName, "img", StringComparison.OrdinalIgnoreCase); if (isImageTag && lowerAttributeName == "srcset") { resolvedValue = ResolveSrcSet(value); return resolvedValue != value; } if (lowerAttributeName != "src" && lowerAttributeName != "background" && lowerAttributeName != "poster") return false; if (TryGetImage(value, out var image)) { resolvedValue = SaveImage(image); return true; } return false; } private string ResolveSrcSet(string srcSetValue) { var candidates = srcSetValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var updatedCandidates = new List(candidates.Length); foreach (var candidate in candidates) { var parts = candidate.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) continue; var imageSource = parts[0]; if (TryGetImage(imageSource, out var image)) imageSource = SaveImage(image); updatedCandidates.Add(parts.Length == 2 ? $"{imageSource} {parts[1]}" : imageSource); } return string.Join(", ", updatedCandidates); } private static bool ShouldDropAttribute(string tagName, string attributeName) { if (attributeName.StartsWith("on", StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(tagName, "body", StringComparison.OrdinalIgnoreCase) && string.Equals(attributeName, "oncontextmenu", StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(attributeName, "srcdoc", StringComparison.OrdinalIgnoreCase)) return true; return false; } private static bool IsUrlAttribute(string attributeName) => string.Equals(attributeName, "href", StringComparison.OrdinalIgnoreCase) || string.Equals(attributeName, "src", StringComparison.OrdinalIgnoreCase) || string.Equals(attributeName, "action", StringComparison.OrdinalIgnoreCase) || string.Equals(attributeName, "xlink:href", StringComparison.OrdinalIgnoreCase) || string.Equals(attributeName, "background", StringComparison.OrdinalIgnoreCase) || string.Equals(attributeName, "poster", StringComparison.OrdinalIgnoreCase); private static bool TrySanitizeUrlValue(string rawValue, out string sanitizedValue) { sanitizedValue = null; if (string.IsNullOrWhiteSpace(rawValue)) return false; var value = rawValue.Trim().Trim('"', '\''); if (value.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) || value.StartsWith("vbscript:", StringComparison.OrdinalIgnoreCase)) return false; if (value.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && !IsAllowedImageDataUrl(value)) return false; sanitizedValue = value; return true; } private static bool IsAllowedImageDataUrl(string value) { const string dataPrefix = "data:"; if (!value.StartsWith(dataPrefix, StringComparison.OrdinalIgnoreCase)) return false; var payloadStart = value.IndexOf(',', StringComparison.Ordinal); if (payloadStart <= dataPrefix.Length) return false; var metadata = value[dataPrefix.Length..payloadStart]; var metadataParts = metadata.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (metadataParts.Length == 0) return false; return AllowedDataImageMimeTypes.Contains(metadataParts[0]); } 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); } private static bool IsCalendarText(MimeEntity entity) => entity is TextPart textPart && textPart.ContentType?.MimeType?.Equals("text/calendar", StringComparison.OrdinalIgnoreCase) == true; 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) { if (entity is ApplicationPkcs7Mime { SecureMimeType: SecureMimeType.EnvelopedData } encrypted) { encrypted.Decrypt().Accept(this); } else if (entity is ApplicationPkcs7Mime { SecureMimeType: SecureMimeType.SignedData } signed) { MimeEntity extracted; VerifySignatures(signed.Verify(out extracted)); extracted.Accept(this); } else { // 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); } } private void VerifySignatures(DigitalSignatureCollection signatures) { foreach (var signature in signatures) { try { bool valid = signature.Verify(); Signatures.Add(signature, valid); } catch (DigitalSignatureVerifyException) { // There was an error verifying the signature. Signatures.Add(signature, false); } } } }