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 : "";
}
}