FluentEmail icon indicating copy to clipboard operation
FluentEmail copied to clipboard

Amazon Simple Email Service (SES) Api Email Sender

Open ticticboooom opened this issue 6 years ago • 17 comments
trafficstars

There is currently an Smtp sender which will support AWS SES Smtp connections, however this is not the prefered way to send emails to the service.

Can there be an AWS SES API sender added to the list of sender packages. This can use the AWSSDK.SimpleEmail Nuget package to make the API Request.

ticticboooom avatar Nov 14 '19 19:11 ticticboooom

I am happy to write this package and test it

ticticboooom avatar Nov 14 '19 19:11 ticticboooom

@KyleBCox I'm not an expert but have been sending emails from a AWS Lambda (on a VPC) through to SES and have had a couple of problems that an implementation might want to be aware of. Sorry if this is a bit of ramble. Although, first, I haven't found anywhere in the documentation on AWS that suggests a preference for SES API over smtp for access. Both are documented.

I have, however, had some problems with the resulting emails via smtp (ie SmtpSender) and having also written a SES API wrapper get a different result. This is what I'll document (this a couple of hours work and hopefully have a full enough picture not to be misleading). My sense is also you need to decide on your credentials strategy as a major factor. Smtp requires SES credentials whereas SES API uses AWS/region credentials. I added code at the bottom to demonstrate this.

Using SmtpSender:

  • resulting email without PlaintextAlternativeBody set doesn't return a multi-part mime email. As such the email doesn't always render well
  • resulting email with PlaintextAlternativeBody (even one space) results in multi-part mime email with the html content base64 encoded (actually more tests show that it doesn't necessarily and haven't worked out where that switching occurs)

Using SES API (see naive implementation below):

  • resulting email is multi-part and the html is not base64 encoded
    /// <summary>
    ///     Implementation (wrapper) for the AWS Simple Email Service.
    /// </summary>
    /// <remarks>
    ///    Ported from <see cref="FluentEmail.Smtp"/> decompiled via JetBrains Rider
    /// </remarks>
    public class AwsSesSender : ISender
    {
        private readonly Func<IAmazonSimpleEmailService> _clientFactory;

        public AwsSesSender(Func<IAmazonSimpleEmailService> clientFactory)
        {
            _clientFactory = clientFactory;
        }

        public SendResponse Send(IFluentEmail email, CancellationToken? token = null)
        {
            return SendAsync(email, token).GetAwaiter().GetResult();
        }

        public async Task<SendResponse> SendAsync(IFluentEmail email, CancellationToken? token = null)
        {
            var response = new SendResponse();
            var mailMessage = CreateMailMessage(email);
            if ((token.HasValue ? (token.GetValueOrDefault().IsCancellationRequested ? 1 : 0) : 0) != 0)
            {
                response.ErrorMessages.Add("Message was cancelled by cancellation token.");
                return response;
            }

            using (var client = _clientFactory())
            {
                await client.SendEmailAsync(mailMessage);
            }

            return response;
        }

        /// <summary>
        ///     Maps and constructs the payload
        /// </summary>
        /// <remarks>
        ///    see https://github.com/awsdocs/amazon-ses-developer-guide/blob/master/doc-source/send-using-sdk-net.md
        /// </remarks>
        private SendEmailRequest CreateMailMessage(IFluentEmail email)
        {
            EmailData data = email.Data;
            var sendRequest = new SendEmailRequest
            {
                Source = data.FromAddress.EmailAddress,
                Destination = new Destination
                {
                    ToAddresses = data.ToAddresses.Select(x => x.EmailAddress).ToList(),
                    BccAddresses = data.BccAddresses.Select(x => x.EmailAddress).ToList(),
                    CcAddresses = data.CcAddresses.Select(x => x.EmailAddress).ToList(),
                },
                Message = new Message
                {
                    Subject = new Content(data.Subject),
                    Body = new Body
                    {
                        Html = new Content
                        {
                            Charset = "UTF-8",
                            Data = data.Body
                        },
                        Text = new Content
                        {
                            Charset = "UTF-8",
                            Data = data.PlaintextAlternativeBody.IfNullOrWhitespace("")
                        },
                    }
                }
            };
            return sendRequest;
        }
    }

Here an extract of some of the code that would go in way to reproduce what I did:

            // injected set of credentials and configuration
            var config = Get<EmailConfig>();
            
            var template = @"<div>Some text {{ text }} in html</div>";
            var model = new {text = "XX"};

            var emailer = new FluentEmail.Core.Email(
                new HandlebarsRenderer(Handlebars.Create()),
                new SmtpSender(() => new SmtpClient(config.Host, config.Port)
                {
                    EnableSsl = true,
                    Credentials = new NetworkCredential(config.Username, config.Password)
                }),
                config.DefaultFromEmail,
                config.DefaultFromName);
           
            await emailer
                .To("[email protected]")
                .Subject("Test smtp to SES with plain text NOT SET")
                .UsingTemplate(template, model)
                .SendAsync();

            await emailer
                .To("[email protected]")
                .Subject("Test smtp to SES with plain text (results in base64 encoded html")
                .PlaintextAlternativeBody(" ")
                .UsingTemplate(template, model)
                .SendAsync();


            await new FluentEmail.Core.Email(
                    new HandlebarsRenderer(Handlebars.Create()),
                    new AwsSesSender(() =>
                        new AmazonSimpleEmailServiceClient(RegionEndpoint.USEast1)),
                    config.DefaultFromEmail,
                    config.DefaultFromName).To("[email protected]")
                .Subject("Test through SES API")
                .UsingTemplate(template, model).SendAsync();

toddb avatar Nov 29 '19 23:11 toddb

@KyleBCox @toddb did anything get implemented here ?

Simonl9l avatar Aug 09 '20 20:08 Simonl9l

@Simonl9l I haven't been back here since I wrote the comment—so I don't know. Sorry.

However, I did a quick look at my code noticed that I have changed it slightly to include a sourceArn that I am injecting from configuration (my tests inject null). Apologies that I haven't documented why I needed the sourceArn :-(

    /// <summary>
    ///     Implementation (wrapper) for the AWS Simple Email Service.
    /// </summary>
    /// <remarks>
    ///    Ported from <see cref="FluentEmail.Smtp"/> decompiled via JetBrains Rider
    /// </remarks>
    public class AwsSesSender : ISender
    {
        private readonly Func<IAmazonSimpleEmailService> _clientFactory;
        private readonly string _sourceArn;

        public AwsSesSender(Func<IAmazonSimpleEmailService> clientFactory, string sourceArn)
        {
            _clientFactory = clientFactory;
            _sourceArn = sourceArn;
        }

        public AwsSesSender(Func<IAmazonSimpleEmailService> clientFactory)
        {
            _clientFactory = clientFactory;
        }

        public SendResponse Send(IFluentEmail email, CancellationToken? token = null)
        {
            return SendAsync(email, token).GetAwaiter().GetResult();
        }

        public async Task<SendResponse> SendAsync(IFluentEmail email, CancellationToken? token = null)
        {
            var response = new SendResponse();
            var mailMessage = CreateMailMessage(email);
            if ((token.HasValue ? (token.GetValueOrDefault().IsCancellationRequested ? 1 : 0) : 0) != 0)
            {
                response.ErrorMessages.Add("Message was cancelled by cancellation token.");
                return response;
            }

            using (var client = _clientFactory())
            {
                await client.SendEmailAsync(mailMessage);
            }

            return response;
        }

        private string FormatEmail(Address address)
        {
            return $"{address.Name} <{address.EmailAddress}>";
        }

        /// <summary>
        ///     Maps and constructs the payload
        /// </summary>
        /// <remarks>
        ///    see https://github.com/awsdocs/amazon-ses-developer-guide/blob/master/doc-source/send-using-sdk-net.md
        /// </remarks>
        private SendEmailRequest CreateMailMessage(IFluentEmail email)
        {
            EmailData data = email.Data;
            var sendRequest = new SendEmailRequest
            {
                // if available use the source arn for authorisation
                SourceArn = _sourceArn,
                //
                Source = FormatEmail(data.FromAddress),
                Destination = new Destination
                {
                    // TODO: have email addresses that are RFC standard with names
                    ToAddresses = data.ToAddresses.Select(x => x.EmailAddress).ToList(),
                    BccAddresses = data.BccAddresses.Select(x => x.EmailAddress).ToList(),
                    CcAddresses = data.CcAddresses.Select(x => x.EmailAddress).ToList(),
                },
                Message = new Message
                {
                    Subject = new Content(data.Subject),
                    Body = new Body
                    {
                        Html = new Content
                        {
                            Charset = "UTF-8",
                            Data = data.Body
                        },
                        Text = new Content
                        {
                            Charset = "UTF-8",
                            Data = data.PlaintextAlternativeBody.IfNullOrWhitespace("")
                        },
                    }
                }
            };
            return sendRequest;
        }
    }

toddb avatar Aug 09 '20 20:08 toddb

Hi all,

If you have something tested and working open a PR and I will try get it merged in.

Luke

lukencode avatar Aug 09 '20 21:08 lukencode

0-60 here is a tad tough...not even familiar with FluentEmail...yet...

My DotNet process is running "as" an IAM, so assume not to need a SourceARN is that is somehow denoting the identity in the SES policy to send the email..

Simonl9l avatar Aug 17 '20 03:08 Simonl9l

OK, this more or less works... given @toddb's code above:

namespace Services.Email
{
    public static class EmailServiceExtensions
    {
        public static IServiceCollection AddEmailServices(this IServiceCollection services, Action<EmailSettings> options = null)
        {
            services
                .Configure(options)
                .AddFluentEmail("<default from email>")
                .AddRazorRenderer()
                .Services.Add(ServiceDescriptor.Scoped(x => (ISender) new AwsSesSender(() => new AmazonSimpleEmailServiceClient(RegionEndpoint.USWest2))));
            return services;
        }
    }
}

and added the services.AddEmailServices to my startup.cs if this helps anyone else.

@lukencode do you have any recommendation on what's needed and supported by RazorLight to embed any css in the email, and add attachments etc.

Simonl9l avatar Aug 17 '20 06:08 Simonl9l

Attachments need to be handled in the ISender implementation. There is an example in the SendGrid sender here: https://github.com/lukencode/FluentEmail/blob/4b9740bb1b1df9750f0d7043f6ca6c72bc39b86f/src/Senders/FluentEmail.SendGrid/SendGridSender.cs#L100

Not sure what sort of CSS embedding you want to do. The most common approach I have seen is using

lukencode avatar Aug 17 '20 06:08 lukencode

@lukencode thank for the attachment pointer...will line up with the AwsSesSender as above...guess this might need to end up in a new package...

I see from the RazorLight docs that Tod recommend using PreMailer to inline CSS.

It seem that one would need a way to insert this in the pipeline between the template renderer and sender, do you have any recommendations there?

It also seems if one has the razor engine "on hand" one can also use IRazorPartialToStringRenderer. I assume this would need to be wrapped in a ITemplateRenderer implementation to be able to use it instead of RazorLight.

Simonl9l avatar Aug 17 '20 23:08 Simonl9l

A new package for the AWS sender is a good idea. Happy for it to be part of the core FluentEmail repo or separately maintained.

I think the FluentEmail template pipeline could use some work. It would be great to be able to do something like:

email.UsePreMailer()

And have that apply to any email body.

lukencode avatar Aug 18 '20 00:08 lukencode

@lukencode I can see myself possibly putting a pull request together for the AWS SES Sender...on the basis this form part of the FluentEmail ecosystem, as I complete current work, especially if @toddb is also able to contribute, but would look for you to be able to embed the use of PreMailer in the service pipeline, no doubt after the render?

So it's more services.AddPreMailer than email.UsePreMailer.

It does however seem at some point if one is sending a lot of the same templated emails to have some kind of pre-rendering/partial capability.

This is possibly where the IRazorPartialToStringRenderer or use of RazorLight Includes comes in such that common parts of the email can be pre-rendered/cached, and included in the template?

Once could then have a header and footer partial, where the header bring in al the CSS etc, once can then render a body, and add the pre-rendered footer in to close the document out, leveraging Custom Sources ?

Simonl9l avatar Aug 18 '20 00:08 Simonl9l

Sounds good @Simonl9l

The Razor renderer does support using layout files to achieve that sort of thing but it is in need of an update to clean everything up.

lukencode avatar Aug 18 '20 00:08 lukencode

Seems some support in RazorLight too and on deeper inspection see that RazorRender can link in to the RazorLight Engine...some more examples there would be great!

Short term what's the best way to integrate PreMailer...?

Simonl9l avatar Aug 18 '20 00:08 Simonl9l

I would love to use FluentEmail with SES support for my next project. I'll keep following this issue.

cjimenezber avatar Sep 10 '20 13:09 cjimenezber

@lukencode - I've been distracted and have now come back to this, and have updated the above code to use the AWSSDK.SimpleEmailV2 give some dependency issues with WSSDK.Core.

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Amazon.SimpleEmailV2;
using Amazon.SimpleEmailV2.Model;
using FluentEmail.Core;
using FluentEmail.Core.Interfaces;
using FluentEmail.Core.Models;

namespace Email.Senders
{
    public class AwsSesSender : ISender
    {
        private readonly Func<IAmazonSimpleEmailServiceV2> _clientFactory;

        public AwsSesSender(Func<IAmazonSimpleEmailServiceV2> clientFactory)
        {
            _clientFactory = clientFactory;
        }

        public SendResponse Send(IFluentEmail email, CancellationToken? token = null)
        {
            return SendAsync(email, token).GetAwaiter().GetResult();
        }

        public async Task<SendResponse> SendAsync(IFluentEmail email, CancellationToken? token = null)
        {
            var response = new SendResponse();
            var mailMessage = CreateMailMessage(email);
            if ((token.HasValue ? (token.GetValueOrDefault().IsCancellationRequested ? 1 : 0) : 0) != 0)
            {
                response.ErrorMessages.Add("Message was cancelled by cancellation token.");
                return response;
            }

            using (var client = _clientFactory())
            {
                await client.SendEmailAsync(mailMessage);
            }

            return response;
        }

        private string FormatEmail(Address address)
        {
            return $"{address.Name} <{address.EmailAddress}>";
        }

        /// <summary>
        ///     Maps and constructs the payload
        /// </summary>
        /// <remarks>
        ///    see https://github.com/awsdocs/amazon-ses-developer-guide/blob/master/doc-source/send-using-sdk-net.md
        /// </remarks>
        private SendEmailRequest CreateMailMessage(IFluentEmail email)
        {
            EmailData data = email.Data;
            var sendRequest = new  SendEmailRequest
            {
                FromEmailAddress = data.FromAddress.EmailAddress,
                // if available use the source arn for authorisation
                Destination = new Destination
                {
                    // TODO: have email addresses that are RFC standard with names
                    ToAddresses = data.ToAddresses.Select(x => x.EmailAddress).ToList(),
                    BccAddresses = data.BccAddresses.Select(x => x.EmailAddress).ToList(),
                    CcAddresses = data.CcAddresses.Select(x => x.EmailAddress).ToList(),
                },
                Content = new EmailContent
                {
                    
                    Simple = new Message()
                    {
                        Subject = new Content
                        {
                            Charset = "UTF-8",
                            Data = data.Subject
                        },
                        Body = new Body {
                            Html = new Content
                            {
                                Charset = "UTF-8",
                                Data = data.Body
                            },
                            Text = new Content
                            {
                                Charset = "UTF-8",
                                Data = string.IsNullOrWhiteSpace(data.PlaintextAlternativeBody) ? "" : data.PlaintextAlternativeBody, 
                            },
                        }
                    }
                }
            };
            return sendRequest;
        }
    }
}

What are your recommendations for getting the AWS SES code into the repo? Where in the structure. I assume under the Senders? I've never done this before...

I also have the layout side of things working in principal, but am at the point were I need to embed some css inline, which brings me back to PreMailer integration?

Where are you with anything here per your comment back in August?

Thanks!

Simonl9l avatar Oct 27 '20 02:10 Simonl9l

@lukencode as additional followup, it seems I'm going to have to redo the above to handle the SES Raw format, as that is required to add attachments, in support of embed images.

It also seems that I'll need to generate the Mime format by hand, do you have any recommendations on if MimeKit is the best choice here? I guess this would be embed in the Sender simple implementation, whereas PreMailer seem better suited in the pipeline.

As an aside I ran into this that might be helpful to others but I this cases the PreMailer.MoveCssInline seems to eliminate unused CSS classes reducing the payload.

Simonl9l avatar Oct 28 '20 00:10 Simonl9l

Is anyone looking into this? I can pick it up if needed.

rajeshpanda avatar Mar 17 '23 05:03 rajeshpanda