Virtual Happy Hour this month, Jun 28, we'll be getting a sneak preview at our soon to launch SaaS CMS!

Try our conversational search powered by Generative AI!

Inline attachments sent through Optimizely

Vote:
 

I have an email provider (SendGrid) I use to automatically trigger emails based on different functions on the CMS / Commerce site. I have a View as a template and some classes that correctly compile and send the emails but the issue is with attachments (and specifically in this case: inline). This specific one has the logo that's set in the backend end and stored in App_Data and attached to the email inline, but there is a specification we need to use the System.Net.Mail.Attachment object instead of any API. I've struggled seeing how to dynamically get the path of this (and all other email attachments to work with the same code). It gets the attachment file's name, content correct but in terms of actually attaching it to the email template, I've been working on this awhile and can't find something to fix this yet.

This is the email service errors in the ForEach block.

Where the uploaded files are located

Exception shown when trying to attach

Line showing how these email attachments are referenced inline

#300258
Apr 17, 2023 14:30
Vote:
 

Hi Andy

Two comments:

  1. Since you are using SendGrid, wouldn't it be easier fot you to store your templates in SendGrid? Then you can just call their API and have them render and send the email. Almost always easier than doing this in code.
  2. It looks like you are storing your logo in the CMS, so editors can manage the logo together with the templates. This is good. But you should not attempt to load the file directly from the App_Data folder.
    Instead it is better to load the media file using IContentLoader. Something like the following:
private Stream GetLogoStream(ContentReference logoReference)
{
    var logoFile = _contentLoader.Get<ImageData>(logoReference, language: null);
    var blob = logoFile.BinaryData;
    using (var blobStream = blob.OpenRead())
    {
        var memoryStream = new MemoryStream();
        blobStream.CopyTo(memoryStream);
        memoryStream.Position = 0;

        return memoryStream;
    }
}

Then you can call this GetLogoStream method when constructing your logo attachment, as it accepts a Stream.

#300542
Apr 22, 2023 18:35
Mike - Apr 25, 2023 16:54
Where would I add this code? The logo isn't the only file that will need to get attached - there are numerous templates and this SendGridEmailService will need to function to work for all attachments, inline and otherwise.
Mike - Apr 25, 2023 17:08
See the post below for more details - I REALLY appreciate your help and would be so grateful if you could help me solve this. If there is another way to work with or get in contact with you please let me know!
Vote:
 

These are all the templates to send out various emails for different actions on the website - there is also a HtmlHelpExtensions class that was already existing that I haven't seen if its being used or not. It seems to have some of the things you've suggested - but this project is over 500 files and spans multiple developers and contractors.

I'm completely lost in this and new to Optimizely, I understand this is a lot to ask but I really appreciate any help in solving this. Basically my hail mary attempt at finishing this.

This is the HtmlHelperExtension class that was existing but was not the piece the old developer built specifically for this SendGrid task (which was initially meant for MailGun and worked with that but the switch to SendGrid caused issues).

 public static class HtmlHelperExtensions
    {
        public static IHtmlString WebCustomersEmailLink(this HtmlHelper htmlHelper, string inlineStyles)
        {
            var email = SiteSettingsHelper.Get(x => x.EmailCustomerSupportEmailAddress);

            var tag = htmlHelper.Anchor($"mailto:{email}", null, null, email);
            if (!string.IsNullOrEmpty(inlineStyles))
            {
                tag.Attributes["style"] = inlineStyles;
            }

            return new MvcHtmlString(tag.ToString());
        }

        public static IHtmlString SiteName(this HtmlHelper htmlHelper)
        {
            return new MvcHtmlString(SiteDefinition.Current.SiteUrl.Host);
        }

        public static IHtmlString InlineFile(this HtmlHelper htmlHelper, string path)
        {
            using (var stream = File.OpenRead(HostingEnvironment.MapPath(path)))
            {
                return htmlHelper.AddInlineAttachment(stream, Path.GetFileName(path));
            }
        }

        public static IHtmlString InlineFile(this HtmlHelper htmlHelper, ContentReference mediaReference)
        {
            if (!ServiceLocator.Current.GetInstance<IContentLoader>().TryGet<MediaData>(mediaReference, out var fileData))
            {
                return MvcHtmlString.Empty;
            }

            using (var stream = fileData.BinaryData.OpenRead())
            {
                return htmlHelper.AddInlineAttachment(stream, fileData.Name);
            }
        }

        private static MvcHtmlString AddInlineAttachment(this HtmlHelper htmlHelper, Stream fileStream, string fileName)
        {
            var length = fileStream.Length;
            var binaryData = new byte[length];
            fileStream.Read(binaryData, 0, (int)length);

            var attachments = (ICollection<Attachment>)htmlHelper.ViewContext.TempData[TemplateCompiler.AttachmentsViewDataProperty];
            attachments.Add(new Attachment(binaryData, fileName, true));
            return new MvcHtmlString("cid:" + fileName);
        }
    }

This class compiles everything and builds the actual emails.

public static class TemplateCompiler
    {
        public const string AttachmentsViewDataProperty = "Attachments";
        public static string Compile(string templatePath, object model, out ICollection<Attachment> attachments)
        {
            return DoRender(templatePath, model, out attachments);
        }

        private static string DoRender(string viewPath, object viewModel, out ICollection<Attachment> attachments)
        {
            var sb = new System.Text.StringBuilder();
            using (var writer = new StringWriter(sb))
            {
                var controllerContext = CreateControllerContext();
                var razorViewResult = ViewEngines.Engines.FindView(controllerContext, viewPath, string.Empty);

                if (razorViewResult.View == null)
                {
                    throw new InvalidOperationException($"Cannot find view {viewPath}.");
                }

                attachments = new List<Attachment>();
                var tempData = new TempDataDictionary
                {
                    [AttachmentsViewDataProperty] = attachments
                };

                var viewContext = new ViewContext(
                    controllerContext,
                    razorViewResult.View,
                    new ViewDataDictionary(viewModel),
                    tempData,
                    writer);

                razorViewResult.View.Render(viewContext, writer);
                return writer.ToString();
            }
        }

        private static ControllerContext CreateControllerContext()
        {
            var routeData = new RouteData();
            routeData.Values.Add("controller", nameof(FakeController));

            // The url below doesn't matter.
            return new ControllerContext(
                new HttpContextWrapper(
                    new HttpContext(
                        new HttpRequest(null, "https://localhost", null),
                        new HttpResponse(null))),
                routeData,
                new FakeController());
        }

        private class FakeController : Controller
        {
        }
    }

I'm going to go through and insert the several relevant classes that are referenced in the MailGunEmailService class - which is the service that is supposed to handle the SendGrid

Attachment

public class Attachment
    {
        public Attachment(byte[] content, string fileName, bool inline = false)
        {
            Content = content;
            FileName = fileName;
            Inline = inline;
        }

        public string FileName { get; }

        public byte[] Content { get; }

        public bool Inline { get; }

        public override string ToString()
        {
            return $"{FileName}, {Content.Length} bytes";
        }

        public static Attachment FromFile(string filePath)
        {
            return new Attachment(File.ReadAllBytes(filePath), Path.GetFileName(filePath));
        }
    }

MailAddress

public class MailAddress
    {
        public MailAddress(string address)
        {
            Address = address;
        }

        public MailAddress(string address, string displayName)
        {
            Address = address;
            DisplayName = displayName;
        }

        private string DisplayName { get; }

        private string Address { get; }

        public override string ToString() => string.IsNullOrEmpty(DisplayName)
            ? Address
            : $"\"{DisplayName}\" <{Address}>";

        /// <summary>
        /// Parses the list of emails to an array of <see cref="MailAddress"/> objects.
        /// </summary>
        /// <param name="emails">The comma- or semicolon- separated list.</param>
        /// <returns>The list of emails or <c>null</c> in case original string is <c>null</c> or empty.</returns>
        public static MailAddress[] FromString(string emails)
        {
            if (string.IsNullOrEmpty(emails))
            {
                return Array.Empty<MailAddress>();
            }

            var split = emails.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries);
            return split.Select(s => new MailAddress(s.Trim())).ToArray();
        }
    }

MailGunEmailService - where everything ties into

public class MailGunEmailService : IEmailService
    {
        private static Logger logger = LogManager.GetCurrentClassLogger();

        public void Send(
            MailAddress from,
            ICollection<MailAddress> to,
            ICollection<MailAddress> cc,
            ICollection<MailAddress> bcc,
            string subject,
            string body,
            ICollection<Attachment> attachments)
        {
            var smtpClient = new SmtpClient();
            var message = new MailMessage();
            message.From = new System.Net.Mail.MailAddress(from.ToString());
            //var request = new RestRequest();

            if (to?.Count > 0)
                foreach (var emailTo in to)
                    message.To.Add(new System.Net.Mail.MailAddress(emailTo.ToString()));

            if (cc?.Count > 0)
                foreach (var carbonCopy in cc)
                    message.CC.Add(new System.Net.Mail.MailAddress(carbonCopy.ToString()));

            if (bcc?.Count > 0)
                foreach (var blindCarbonCopy in bcc)
                    message.Bcc.Add(new System.Net.Mail.MailAddress(blindCarbonCopy.ToString()));

            message.Subject = subject;
            message.Body = body;
            message.IsBodyHtml = true;
            
            // Need to get the path(s) to the file(s) and add to email using System.Net.Mail.Attachments()
            if (attachments != null)
            {
                foreach (var attachment in attachments.Where(a => !a.Inline))
                    message.Attachments.Add(new System.Net.Mail.Attachment(attachment.FileName));

                foreach (var attachment in attachments.Where(a => a.Inline))
                {
                    var att = new System.Net.Mail.Attachment(attachment.FileName);
                    att.ContentId = attachment.FileName;
                    att.ContentDisposition.Inline = true;
                    att.ContentDisposition.DispositionType = DispositionTypeNames.Inline;

                    message.Attachments.Add(att);
                }
            }

            smtpClient.Send(message);
        }
    }
#300700
Apr 25, 2023 17:07
Vote:
 

Hi Mike

First off, Mailgun has a template renderer on its own. This means you can put your template there and just send your parameters to their API. Then you can remove all your renderer code. That's what I always prefer.

It looks like you are hardcoding the email content in a view file. If you use the InlineFile(this HtmlHelper htmlHelper, ContentReference mediaReference), then the data is loaded from the blob file (wherever that is stored). That is the recommended way.

The Mailgun class you have should already be neutral enough to also support Sendgrid, since it is just a SMTP client wrapper. What kind of issues do you have with Sendgrid?

#300709
Apr 25, 2023 18:31
Vote:
 

I can confirm now the issue happened when switching from MailGun (using the API) to the SendGrid (using SMTP). The requirement is to use SendGrid SMTP to send and not the Rest API method. I think the issue is that we never had any SMTP version of this working, whether it was MailGun or SendGrid. The code below is the last working version of what we had but its using the API - I'm almost sure what I need is to have this same MailGunEmailService class have the stuff to send those attachments (that are from blob storage). This class and methods get called and work for sending but the issue of inline and attachments is really the only issue with all of this. Whatever missing pieces or things I need to add/modify to reconstruct this class to account for any attachments (including inline).

This is some code I wrote - before I realized it was the wrong way to save these files like this and that the files are actually coming from Optimizely. This actually did work for inline attachments but it's obviously not the right way to do it. Which is why I'm going back to fix before we launch it. Let me know if this makes anything clearer, I really appreciate your time.

internal class MailGunEmailService : IEmailService
    {
        private static Logger logger = LogManager.GetCurrentClassLogger();
        private readonly string path = "~/App_Data/attachments/";

        public void Send(
            MailAddress from,
            ICollection<MailAddress> to,
            ICollection<MailAddress> cc,
            ICollection<MailAddress> bcc,
            string subject,
            string body,
            ICollection<Attachment> attachments)
        {
            var smtpClient = new SmtpClient();
            var message = new MailMessage();
            message.From = new System.Net.Mail.MailAddress(from.ToString());
            //var request = new RestRequest();

            if (to != null && to.Count > 0)
                foreach (var emailTo in to)
                    message.To.Add(new System.Net.Mail.MailAddress(emailTo.ToString()));

            if (cc != null && cc.Count > 0)
                foreach (var carbonCopy in cc)
                    message.CC.Add(new System.Net.Mail.MailAddress(carbonCopy.ToString()));


            if (bcc != null && bcc.Count > 0)
                foreach (var blindCarbonCopy in bcc)
                    message.Bcc.Add(new System.Net.Mail.MailAddress(blindCarbonCopy.ToString()));

            message.Subject = subject;
            message.Body = body;
            message.IsBodyHtml = true;

            // Attachments currently do not work
            // Need to get the path(s) to the file(s) and add to email using System.Net.Mail.Attachments()
            if (attachments != null)
            {
                foreach (var attachment in attachments.Where(a => !a.Inline))
                    message.Attachments.Add(new System.Net.Mail.Attachment(attachment.FileName));

                foreach (var attachment in attachments.Where(a => a.Inline))
                {
                    if (!Directory.Exists(HostingEnvironment.MapPath(path)))
                        Directory.CreateDirectory(HostingEnvironment.MapPath(path) ?? string.Empty);

                    var attachmentPath = Path.Combine(HostingEnvironment.MapPath(path) ?? string.Empty,
                        attachment.FileName);
                    File.WriteAllBytes(attachmentPath, attachment.Content);

                    var att = new System.Net.Mail.Attachment(attachmentPath);
                    att.ContentDisposition.Inline = true;
                    att.ContentId = attachment.FileName;
                    att.ContentDisposition.DispositionType = DispositionTypeNames.Inline;
                    message.Attachments.Add(att);
                }
            }

            smtpClient.Send(message);
        }
    }
#300710
Apr 25, 2023 18:58
Vote:
 

Are the attachments added to the email, but just not shown in the body? Or are they not added at all?

If they are added, but not shown, a simple thing you can try is to add <> around your file name in the ContentId value. Like this:

att.ContentId = $”<{attachment.FileName}>”;
#300711
Edited, Apr 25, 2023 20:41
Vote:
 

They are not added at all. The last code I posted did have attachments work but it was only because I was saving them again and attaching a hardcoded file path. The files are already stored in App_Data blob folders because they come from uploads to the CMS.

#300712
Apr 25, 2023 21:03
* You are NOT allowed to include any hyperlinks in the post because your account hasn't associated to your company. User profile should be updated.