Skip to content

Instantly share code, notes, and snippets.

@georg-jung
Created April 12, 2022 18:05
Show Gist options
  • Save georg-jung/9a2b97130fb2cf6e75dbbcf61fca5e59 to your computer and use it in GitHub Desktop.
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.
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