Created
April 12, 2022 18:05
-
-
Save georg-jung/9a2b97130fb2cf6e75dbbcf61fca5e59 to your computer and use it in GitHub Desktop.
An IRenderTask implementation for NewsletterStudio 3 that embeds any images used in the mails sent as inline attachments. Inline attachments are not shown as attachments by typical mail clients.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Net.Http; | |
using System.Net.Mail; | |
using System.Runtime.Caching; | |
using System.Threading.Tasks; | |
using HtmlAgilityPack; | |
using NewsletterStudio.Core.Rendering; | |
using NewsletterStudio.Core.Rendering.Tasks; | |
#nullable enable | |
namespace MyUmbracoProject.Newsletter | |
{ | |
public sealed class ImageEmbedderRenderTask : IRenderTask, IDisposable | |
{ | |
private const string MAILER_IDENTIFIER = "@mail.example.com"; | |
private readonly HttpClient _httpClient = new(); | |
public void Process(RenderTaskProcessingResult result, RenderTaskParameters parameters) | |
{ | |
// the cid stuff would break the browser based preview rendering | |
if (parameters.RenderingMode == RenderingMode.Preview) return; | |
var docNode = result?.Body?.DocumentNode; | |
var imgNodes = docNode?.SelectNodes("//img[@src]"); | |
if (imgNodes == null) return; | |
Dictionary<string, Attachment> atts = new(imgNodes.Count); | |
foreach (var imgNode in imgNodes) | |
{ | |
var src = imgNode.Attributes["src"]; | |
if (src?.Value == null) continue; | |
// dotnet framework case insensitive Contains, see https://stackoverflow.com/a/444818/1200847 | |
// skip tracking pixel | |
if (src.Value.IndexOf("__ns/t", StringComparison.OrdinalIgnoreCase) >= 0) continue; | |
HandleAttribute(src, atts); | |
} | |
foreach (var att in atts.Values) | |
{ | |
parameters.MailMessage.Attachments.Add(att); | |
} | |
} | |
private void HandleAttribute(HtmlAttribute imgSrc, Dictionary<string, Attachment> attachments) | |
{ | |
var url = imgSrc.Value; | |
var mediaId = GetMediaID(url); | |
if (!attachments.TryGetValue(mediaId, out var att)) | |
{ | |
// sync over async: https://stackoverflow.com/a/69075991/1200847 | |
var imageRes = Task.Run(() => GetImageResourceFromUrl(url)).GetAwaiter().GetResult(); | |
att = new Attachment(imageRes.ImageStream, imageRes.MediaID, imageRes.MediaType) | |
{ | |
ContentId = imageRes.MediaID + MAILER_IDENTIFIER, | |
}; | |
att.ContentDisposition.Inline = true; | |
attachments.Add(mediaId, att); | |
} | |
imgSrc.Value = "cid:" + att.ContentId; | |
} | |
/// <summary> | |
/// Returns an ImageResource object for the image available at the specified URL. The object is provided from the cache if possible. | |
/// </summary> | |
private async Task<ImageResource> GetImageResourceFromUrl(string url) | |
{ | |
var cache = MemoryCache.Default; | |
var mediaId = GetMediaID(url); | |
if (cache.Get(mediaId) is not ImageResource imgRes) | |
{ | |
imgRes = await DownloadImage(url).ConfigureAwait(false); | |
cache.Set(mediaId, imgRes, DateTimeOffset.UtcNow.AddMinutes(10)); | |
} | |
return imgRes; | |
} | |
private async Task<ImageResource> DownloadImage(string url) | |
{ | |
using var req = new HttpRequestMessage(HttpMethod.Get, url); | |
using var res = await _httpClient.SendAsync(req).ConfigureAwait(false); | |
// Todo: Maybe we should do something else if we can not download one of the images? | |
// An option would be to just skip it. On the other hand we send newsletters that | |
// perform differently then we think they would without anyone noticing. | |
// Thus, fail early for the moment. | |
res.EnsureSuccessStatusCode(); | |
var bytes = await res.Content.ReadAsByteArrayAsync().ConfigureAwait(false); | |
return new(bytes, res.Content.Headers.ContentType.MediaType, GetMediaID(url)); | |
} | |
/// <summary> | |
/// Generates a unique identifier from a URL, which is used as a MIME CID and as cache key. | |
/// </summary> | |
private static string GetMediaID(string value) => CreateMD5(value.ToUpperInvariant()); | |
// taken and modified from https://stackoverflow.com/a/24031467/1200847 | |
/// <summary> | |
/// Do not use this in security related contexts. | |
/// </summary> | |
private static string CreateMD5(string input) | |
{ | |
using var md5 = System.Security.Cryptography.MD5.Create(); | |
var inputBytes = System.Text.Encoding.UTF8.GetBytes(input); | |
var hashBytes = md5.ComputeHash(inputBytes); | |
return ByteArrayToString(hashBytes); | |
} | |
// See https://stackoverflow.com/a/624379/1200847 | |
// There are much faster and much less readable ways to do this. | |
// For .NET 5+ use | |
// Convert.ToHexString(hashBytes); | |
private static string ByteArrayToString(byte[] ba) => BitConverter.ToString(ba).Replace("-", ""); | |
public void Dispose() | |
{ | |
_httpClient.Dispose(); | |
} | |
private class ImageResource | |
{ | |
public System.IO.MemoryStream ImageStream => new(Data, false); | |
public byte[] Data { get; } | |
public string MediaType { get; } | |
public string MediaID { get; } | |
public ImageResource(byte[] data, string mediaType, string mediaID) | |
{ | |
Data = data; | |
MediaType = mediaType; | |
MediaID = mediaID; | |
} | |
} | |
} | |
} | |
#nullable restore |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment