using System.Text.RegularExpressions; namespace MailSharp.MailClient.Services; // When external images are blocked for a sender, src attributes are replaced with a // harmless placeholder. When allowed, the src is rewritten to a local // /cache/images/{emailAddress}/{messageId}/{01, 02, ...}.ext path - numbered in the order the // images appear in the message, so it's obvious which cached files belong to which message - and // the original URL is kept both in a data-url attribute and in a mapping so the image handler can // download and cache it the first time it's actually requested. public partial class ImageSanitizer(IMailCacheStore cacheStore) { [GeneratedRegex(@"(]*?\bsrc\s*=\s*)(['""])(https?://[^'""]+)\2", RegexOptions.IgnoreCase)] private static partial Regex ImgSrcRegex(); [GeneratedRegex(@"(]*?\bhref\s*=\s*)(['""])(https?://[^'""]+)\2", RegexOptions.IgnoreCase)] private static partial Regex AnchorHrefRegex(); // Message HTML renders inside a sandboxed iframe (sandbox="allow-popups", no // allow-top-navigation) - clicking a link as-is would just be silently blocked by that // sandbox rather than doing anything the user would recognize as "not working" (and the // remote site refusing to be framed makes it look like their fault instead). Rewriting every // external href to our own interstitial (see MailController.ExternalLink) fixes both: it opens // in a real top-level tab (never framed, so no site can refuse it), and gives the user an "you // are leaving MailSharp" checkpoint before a sender-controlled link runs. public string ApplyLinkPolicy(string html) { if (string.IsNullOrEmpty(html)) return html; return AnchorHrefRegex().Replace(html, m => { var prefix = m.Groups[1].Value; var quote = m.Groups[2].Value; var url = m.Groups[3].Value; var interstitialHref = "Mail/ExternalLink?url=" + Uri.EscapeDataString(url); return $"{prefix}{quote}{interstitialHref}{quote} target=\"_blank\" rel=\"noopener noreferrer\""; }); } public (string Html, bool HasExternalImages) ApplyPolicy(string html, bool allowExternalImages, string emailAddress, string messageId) { if (string.IsNullOrEmpty(html)) return (html, false); var hasExternal = false; var index = 0; var result = ImgSrcRegex().Replace(html, m => { hasExternal = true; var prefix = m.Groups[1].Value; var quote = m.Groups[2].Value; var url = m.Groups[3].Value; if (!allowExternalImages) { return $"{prefix}{quote}about:blank{quote} data-blocked-src={quote}{url}{quote}"; } index++; var extension = ExtractExtension(url); var fileName = $"{index:D2}{extension}"; cacheStore.SaveImageSourceUrl(emailAddress, messageId, fileName, url); var src = $"cache/images/{Uri.EscapeDataString(emailAddress)}/{Uri.EscapeDataString(messageId)}/{fileName}"; return $"{prefix}{quote}{src}{quote} data-url={quote}{url}{quote}"; }); return (result, hasExternal); } private static string ExtractExtension(string url) { var qIndex = url.IndexOf('?'); var basePart = qIndex >= 0 ? url[..qIndex] : url; var lastSlash = basePart.LastIndexOf('/'); var lastSegment = lastSlash >= 0 ? basePart[(lastSlash + 1)..] : basePart; var dotIndex = lastSegment.LastIndexOf('.'); if (dotIndex <= 0) return ""; var sanitized = new string([.. lastSegment[(dotIndex + 1)..].Where(char.IsLetterOrDigit)]); return sanitized.Length > 0 ? "." + sanitized : ""; } }