How to set up SES on AWS so you can send emails from Rails cheaply in production

19 May 2015

Sending emails from Rails is not that hard, but keeping them out of user's spam folders can be difficult. The first time I set up a production rails app as an MVP for a project, a significant number of users didn't complete sign up because the account confirmation email went straight to their spam folder. There are a lot of reasons this can happen, and a fair number of tools online that will tell you what they are. Fixing the issues often involves sending mail through a delivery partner so that bounces and sender reputation are handled cleanly, but the providers vary a lot in what they offer.

Sendgrid offers a lot of nice features, but is relatively expensive, so why use it if you're just sending simple emails and don't need the extra features? SES on AWS is roughly ten times cheaper, so can be a very good option, but the setup is a lot more complex. Your app requires specialist bounce handling before they'll let you send real emails outside of the sandbox, so you need to write a fair bit of code. The idea is that when an email address is dead, your app hears about it via the bounce notification and stops sending emails to it. Without this, both you and AWS are going to get a reputation as spammers, and your deliverability will suffer so it's a good thing that they insist on this.

This post will show you how to get it working. Before you start, you'll need:

  • An AWS account with admin access
  • A production Rails app hosted live under the production domain name
  • HTTPS set up and working for your production app with a valid certificate
  • Administrator access to the domain registrar account for the production domain.
  • Access to emails that are sent to the 'from' address that is set up in your app

Step 1: Set the IAM SMTP user and add the credentials to your action mailer config.

To send email from Rails, you need an SMTP username and password.

To get this for SES, start by logging into the AWS management console and head to the SES section. Then, go to SMTP Settings and click the Create My SMTP Credentials button. Working through the steps it presents, you can accept the defaults and it will give you a new IAM user with a username and password combination (More info here). Make a note of these, and add them to your production application's SMTP settings.

Step 2: Verify your domain and enable DKIM.

Now we have to prove to AWS and others that your domain is actually yours and that you're not a spammer. This is key to keeping your mails in the inboxes of your recipients where they belong.

Add your production domain to the list on the Domains page within the SES section. To do this, click the Verify a New Domain button, enter the domain name, and make sure you tick the Generate DKIM Settings box. If you don't know why DKIM matters, you can get a pretty good overview from Wikipedia here. In short, it makes it harder for spammers to send messages that look like they're from your domain when they're not.

In the next stage, AWS will show you some DNS records that you will need to add to your existing DNS records with your registrar. Your registrar will have their own instructions for how you can do this, so the next step is to find these instructions and follow them. More information on the process is in the AWS docs here.

Domain verification is automatic and can take some time. For me it was a couple of hours. Once it's done, make sure you turn on the DKIM setting for the domain in the SES settings.

Step 3: Verify your from address.

Similarly, AWS need to know that you're not maliciously sending email with someone else's account as the from address.

You should have the address you want to use set up in Rails already. Something like [email protected]. This appears in the SMTP settings (or in your mailers). Go to the Email addressed page in the SES settings and click the Verify a New Email Address button. This is pretty straightforwards - you just click on the confirmation link in the email you receive. However, it's possible that the address does not have a mailbox, so you will need to make one first, or get it to forward messages to your personal address temporarily.

Once this is done, turn on DKIM for that address too.

Step 4: Add an SNS topic and add your own email address to it

We now need a way for the bounces to be routed to your app, and SNS is the way AWS handles this. Adding your own email to start with means that bounce will be forwarded to you so you can verify that the SNS topic is working.

Go to the SNS section of the AWS console, click on the Topics link, and then Create a new topic. Do this twice, once for yourapp_bounces, and once for yourapp_complaints.

For each of them, click on the link in the list that starts with 'arn', then click Create subscription, then choose email and enter your personal email address. This is just for testing, so remember to remove this afterwards.

You are now ready to test that the bounce and complaint handling will pick up correctly when your app sends mail.

Go to your app and do whatever it takes to get an email sent to the bounce and complaint testing addresses that you can find here. You should find that the SNS topic sends a notification to your personal email address informing you of the problem.

Step 5: Add bounce handling in your app.

To stop future emails being send to dead addresses, your app needs to store a record of the email having bounced and will mark the address as invalid. The process is:

  1. SNS should call a HTTPS post endpoint in your app (instead of sending an email to you)
  2. The endpoint verifies that it's really AWS using the AWS SDK (certificate signing)
  3. The email address is then marked as being a bounce in your app's DB
  4. Your app's mailers always check this list before sending

It's a good idea to make sure that you and your users can see the bounces and complaints in the application UI somewhere, otherwise you'll get users filing bug reports if they've misspelled their email.

Here's the code I used to implement the bounce mechanism:

Controller Tests

These are just a guide. You may not be using Rspec or VCR (maybe you should :), but this shows the general idea.

require 'rails_helper'

describe EmailResponsesController do
  let(:user) { create :user }
  let(:other_user) { create :user }

  describe 'POST bounce' do

    let(:bounce_json) do
      %q{
        {
          "Type" : "Notification",
          "MessageId" : "2d222228-90e1-5ba1-9784-9c60222222e5",
          "TopicArn" : "arn:aws:sns:eu-west-1:863122222282:yourapp_bounces",
          "Message" : "{\"notificationType\":\"Bounce\",\"bounce\":{\"bounceSubType\":\"General\",\"bounceType\":\"Permanent\",\"bouncedRecipients\":[{\"status\":\"5.1.1\",\"action\":\"failed\",\"diagnosticCode\":\"smtp; 550 5.1.1 user unknown\",\"emailAddress\":\"[email protected]\"}],\"reportingMTA\":\"dsn; a6-26.smtp-out.eu-west-1.amazonses.com\",\"timestamp\":\"2015-01-21T00:37:34.429Z\",\"feedbackId\":\"00000122222225fb-9a217d45-8eed-4403-b7d9-172222222be7-000000\"},\"mail\":{\"timestamp\":\"2015-01-21T00:37:33.000Z\",\"source\":\"[email protected]\",\"destination\":[\"[email protected]\"],\"messageId\":\"000222222222429c-49e59593-7f16-44e3-b5a2-1a222222222c6-000000\"}}",
          "Timestamp" : "2015-01-21T00:37:34.452Z",
          "SignatureVersion" : "1",
          "Signature" : "ZmtUp+wmfSNUW0dGkzVN9A9Q7R88KwfU32zXTHn43pppZAd6pJlwKRdH/B9Ui4M1Sd1gC1Zi2WgLtC2Xi7kf60bdi66a222222222222QKeRy1GbqWfT9kcOvm4aAFlRy0FoDu2FD9iyGPu62RsABlNzLYGNI1YnmIKwHSXXy/St4MMwwGjGLprZsRSSimM/B9VJr5WCzwMmKYr/kWGVr3pN8B5WMRuFw0pYBIemJoZugkHElEypz8KNVRxSwRN3Iz7me38mMDPCs1xiv/6IyNQXu1pdtEWkAPb26feK3SuBpPNpTQtbnaZvt7wNSw==",
          "SigningCertURL" : "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-d6d622222222222222221f4f9e198.pem",
          "UnsubscribeURL" : "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:82222222222282:yourapp_bounces:222222224f-7a0a-4e07-b34d-4a662222222226"
        }

      }
    end

    let(:invalid_bounce_json) do
      %q{
        {
          "Type" : "Notification",
          "MessageId" : "2222222222228-90e1-5ba1-9784-9c2222222222225",
          "TopicArn" : "arn:aws:sns:eu-west-1:862222222282:yourapp_bounces",
          "Message" : "{\"notificationType\":\"Bounce\",\"bounce\":{\"bounceSubType\":\"General\",\"bounceType\":\"Permanent\",\"bouncedRecipients\":[{\"status\":\"5.1.1\",\"action\":\"failed\",\"diagnosticCode\":\"smtp; 550 5.1.1 user unknown\",\"emailAddress\":\"[email protected]\"}],\"reportingMTA\":\"dsn; a6-26.smtp-out.eu-west-1.amazonses.com\",\"timestamp\":\"2015-01-21T00:37:34.429Z\",\"feedbackId\":\"0000022222222222fb-9a217d45-8eed-4403-b7d9-17222222222227-000000\"},\"mail\":{\"timestamp\":\"2015-01-21T00:37:33.000Z\",\"source\":\"[email protected]\",\"destination\":[\"[email protected]\"],\"messageId\":\"0000022222222222c-49e59593-7f16-44e3-b5a2-1222222222226-000000\"}}",
          "Timestamp" : "2015-01-21T00:37:34.452Z",
          "SignatureVersion" : "1",
          "Signature" : "ZmtUp+wmfSNUW0dGkzVN9A9Q7R88KwfU3222222222222gC1Zi2WgLtC2Xi7kf60bdi66aY/R76EV80xL/Znfn9YYQKeRy1GbqWfT9kcOvm4aAFlRy0FoDu2FD9iyGPu62RsABlNzLYGNI1YnmIKwHSXXy/St4MMwwGjGLprZsRSSimM/B9VJr5WCzwMmKYr/kWGVr3pN8B5WMRuFw0pYBIemJoZugkHElEypz8KNVRxSwRN3Iz7me38mMDPCs1xiv/6IyNQXu1pdtEWkAPb26feK3SuBpPNpTQtbnaZvt7wNSw==",
          "SigningCertURL" : "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-d6d6792222222222222222222229e198.pem",
          "UnsubscribeURL" : "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:8622222222222222:yourapp_bounces:28222222222224f-7a0a-4e07-b34d-422222222222f6"
        }

      }
    end

    it 'creates a new bounce record' do
      expect {
        VCR.use_cassette 'check_sns_message' do
          post :bounce, bounce_json, format: :json
        end
      }.to change(EmailResponse, :count).by(1)
    end

    it 'saves the information it should' do
      allow(EmailResponse).to receive(:create)
      expect(EmailResponse).to receive(:create).with(hash_including(email: '[email protected]', response_type: 'bounce', extra_info: "status: 5.1.1, action: failed, diagnosticCode: smtp; 550 5.1.1 user unknown"))
      VCR.use_cassette 'check_sns_message' do
        post :bounce, bounce_json, format: :json
      end
    end

    it 'fails if it cannot verify the message' do
      expect {
        VCR.use_cassette 'check_sns_message' do
          post :bounce, invalid_bounce_json, format: :json
        end
      }.to change(EmailResponse, :count).by(0)
    end
  end

  describe 'POST complaint' do

    let(:complaint_json) do
      %q{
        {
          "Type" : "Notification",
          "MessageId" : "092cf767-9156-5244-b2d5-ba2222225436",
          "TopicArn" : "arn:aws:sns:eu-west-1:8622222222243:yourapp_email_complaints",
          "Message" : "{\"notificationType\":\"Complaint\",\"complaint\":{\"complaintFeedbackType\":\"abuse\",\"complainedRecipients\":[{\"emailAddress\":\"[email protected]\"}],\"userAgent\":\"Amazon SES Mailbox Simulator\",\"timestamp\":\"2015-01-21T12:56:53.000Z\",\"feedbackId\":\"0000014b022222e7-f934da45-a45c-11e4-9559-192222eb80f1-000000\"},\"mail\":{\"timestamp\":\"2015-01-21T12:56:52.000Z\",\"source\":\"[email protected]\",\"destination\":[\"[email protected]\"],\"messageId\":\"0000014b0c911fd2-c7eb049f-f610-4b56-6541-2e2222249521-000000\"}}",
          "Timestamp" : "2015-01-21T12:56:54.396Z",
          "SignatureVersion" : "1",
          "Signature" : "u7u/zeRG5KC3K7CpXll22222fwbrAGMn9rZZTTxmy2vrIyioEpIXtCaT6MhjUum2erYBi0Doo8K0/nmD+vNJMK43+IGtqsjQZeEwtr6cWDDJyrxoX53a18fp9YqBNTzwvu9TOkTNn3fUhqbumw9fH1+ltQ3qeDRP1DrpkJczQ080cZPmkF2xeDL2222IDlZJJkWpvivIrt9ZBS/lW4HU0UpjvHVAZhxgZyUoWuRMOM7j3q3aRh/RB9aHOOAw8wdfg5ie8vHSbcEOVdj2fakGfUM3kCVrgm983AjJt2SA==",
          "SigningCertURL" : "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-d6d679a1d2222522222222222198.pem",
          "UnsubscribeURL" : "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:863132222282:yourapp_email_complaints:0222222c-d183-45d8-b0ac-787d02222084"
        }
      }
    end

    let(:invalid_complaint_json) do
      %q{
        {
          "Type" : "Notification",
          "MessageId" : "092cf767-9156-5244-b2d5-ba5222222d80",
          "TopicArn" : "arn:aws:sns:eu-west-1:863122222282:yourapp_email_complaints",
          "Message" : "{\"notificationType\":\"Complaint\",\"complaint\":{\"complaintFeedbackType\":\"abuse\",\"complainedRecipients\":[{\"emailAddress\":\"[email protected]\"}],\"userAgent\":\"Amazon SES Mailbox Simulator\",\"timestamp\":\"2015-01-21T12:56:53.000Z\",\"feedbackId\":\"0000014b0c9126e7-f922222b-a16c-11e4-9559-122221eb80f1-000000\"},\"mail\":{\"timestamp\":\"2015-01-21T12:56:52.000Z\",\"source\":\"[email protected]\",\"destination\":[\"[email protected]\"],\"messageId\":\"0000014b022222d2-c722222f-f610-4b56-1237-2e2222249521-000000\"}}",
          "Timestamp" : "2015-01-21T12:56:54.396Z",
          "SignatureVersion" : "1",
          "Signature" : "u7u/zeRG5KC3K7CpXllfwbrAGMqcHsp/01d3JAwxwPZYuOU22222222Gn9rZZTTxmy2vrIyioEpIXtCaT6MhjUum2erYBi0Doo8K03+IGtqsjQZeEwtr6cWDDJyrxoX53a18fp9YqBNTzwvu9TOkTqbumw9fH1+ltQ3qeDRP1DrpkJczQ080cZPmkF2xeDLlt5aIDlZJJkWpvivIrt9ZBS/lW4HU0UpjvHVAZhxgZyUoWuRMOM7j3q3aRh/RB9aHOOAw8wdfg5ie8vHSbcEOVIfOziENBEgYCctpVMgZDjdj2fakGfUM3kCVrgm983AjJt2SA==",
          "SigningCertURL" : "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-d6d679a1d2222222222e198.pem",
          "UnsubscribeURL" : "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:863132222282:yourapp_email_complaints:0222222c-d183-45d8-b0ac-787222222084"
        }
      }
    end

    it 'creates a new complaint record' do
      expect {
        VCR.use_cassette 'check_sns_message' do
          post :complaint, complaint_json, format: :json
        end
      }.to change(EmailResponse, :count).by(1)
    end

    it 'saves the information it should' do
      allow(EmailResponse).to receive(:create)
      expect(EmailResponse).to receive(:create).with(hash_including(email: '[email protected]', response_type: 'complaint', extra_info: "complaintFeedbackType: abuse"))
      VCR.use_cassette 'check_sns_message' do
        post :complaint, complaint_json, format: :json
      end
    end

    it 'fails if it cannot verify the message' do
      expect {
        VCR.use_cassette 'check_sns_message' do
          post :complaint, invalid_complaint_json, format: :json
        end
      }.to change(EmailResponse, :count).by(0)
    end
  end
end

DB schema

# In a migration file in db/migrations. Use rails g migration to make one

class CreateEmailResponses < ActiveRecord::Migration
  def change
    create_table :email_responses do |t|
      t.string :email
      t.text :extra_info
      t.integer :response_type

      t.timestamps null: false
    end
  end
end

Model

# In app/models/email_response.rb

class EmailResponse < ActiveRecord::Base
  enum response_type: [ :bounce, :complaint ]

  validates_presence_of :email
end

Controller

# In app/controllers/email_responses_controller.rb

require 'json'

class EmailResponsesController < ApplicationController

  skip_authorization_check
  skip_before_action :verify_authenticity_token

  before_action :log_incoming_message

  def bounce
    return render json: {} unless aws_message.authentic?

    if type != 'Bounce'
      Rails.logger.info "Not a bounce - exiting"
      return render json: {}
    end

    bounce = message['bounce']
    bouncerecps = bounce['bouncedRecipients']
    bouncerecps.each do |recp|
      email = recp['emailAddress']
      extra_info  = "status: #{recp['status']}, action: #{recp['action']}, diagnosticCode: #{recp['diagnosticCode']}"
      Rails.logger.info "Creating a bounce record for #{email}"

      EmailResponse.create ({ email: email, response_type: 'bounce', extra_info: extra_info})
    end

    render json: {}
  end

  def complaint
    return render json: {} unless aws_message.authentic?

    if type != 'Complaint'
      Rails.logger.info "Not a complaint - exiting"
      return render json: {}
    end

    complaint = message['complaint']
    recipients = complaint['complainedRecipients']
    recipients.each do |recp|
      email = recp['emailAddress']
      extra_info = "complaintFeedbackType: #{complaint['complaintFeedbackType']}"
      EmailResponse.create ( { email: email, response_type: 'complaint', extra_info: extra_info } )
    end

    render json: {}
  end

  protected

  def aws_message
    @aws_message ||= AWS::SNS::Message.new request.raw_post
  end

  def log_incoming_message
    Rails.logger.info request.raw_post
  end

  # Weirdly, AWS double encodes the JSON.
  def message
    @message ||= JSON.parse JSON.parse(request.raw_post)['Message']
  end

  def type
    message['notificationType']
  end
end

Routes

# In config/routes.rb

post 'email_responses/bounce' => 'email_responses#bounce'
post 'email_responses/complaint' => 'email_responses#complaint'

Mailer intercept

# In config/initializers/invalid_email_interceptor.rb

class BouncedEmailInterceptor
  def self.delivering_email(message)
    if EmailResponse.exists? email: message.to
      message.perform_deliveries = false
    end
  end
end

ActionMailer::Base.register_interceptor(BouncedEmailInterceptor)

You may be tempted to just use a column in the user table for marking the email address as invalid or not, but you may end up having more than one bounce/complaint per email address, and the information might be very useful in knowing whether you want to unblock the address (server may be down, mailbox full, etc). You may also have multiple addresses per user over time, so a separate table is better.

Step 6: Add the HTTPS endpoint to your SNS topic

This is where you actually get the SNS topic to ping you app when a bounce happens.

Go back to the SNS section of the AWS console, and click on the bounces topic you created and then click the Create subscription button. Choose https and enter https://www.yourdomain.com/email_responses/bounce. Do the same for the complaints topic, altering the URL as appropriate.

Now repeat whatever action in your app will send an email to the bounce test address. You should find that a new record is created in the database. Repeat the action, and the logs should show that no email was sent this time.

Then do the same test for the complaints test address.

Once you're happy this works, remove the subscription that copies the emails to your personal email for each of the SNS topics, and you're all done.

Step 7: Ask AWS to enable production SES access.

Now that your app can handle bounces and complaints, you are OK to ask AWS to take you out of the sandbox and enable actual emails to be sent from your app. There are instructions on how to do this here.

All done!

That's it. You should be able to send transactional emails cheaply and easily now using the AWS infrastructure. Let's hope Amazon keep dropping their prices like they have done in the past!

Mountain Road logo