Moon image
Back to posts

Setting Up AWS SES with Rails Action Mailer

Sending emails isn't just about building infrastructure and software - the real challenge lies in ensuring your emails reach users' inboxes without being flagged as spam. In my experience with email SaaS providers, despite their premium pricing, emails often ended up in spam folders.

Enter AWS Simple Email Service (SES). AWS SES offers a compelling solution with:

  • Strong reputation and low spam scores
  • Competitive pricing
  • Flexible implementation (aws-ses-sdk or SMTP)

Using AWS SES in us-west-1 region costs $0.10 per 1,000 emails. While the pricing model is complex (considering attachments and external network delivery), its pay-as-you-go structure works well for low-volume users. In my case, monthly costs never exceed $1, with a 100% delivery rate.

Setup Process

Important: New AWS SES accounts start in sandbox mode. You'll need to open a support ticket to upgrade to production status.

While AWS provides aws-sdk-ses, I prefer using SMTP to avoid vendor lock-in. Here's how to set it up:

Get SMTP Credentials:

Navigate to Amazon SES --> SMTP settings --> Create SMTP Credentials. Save these credentials securely - they can't be recovered once lost

Amazon SES --> SMTP settings --> Create SMTP Credentials

Configure Rails

We got the credentials. Next from rails, open config/development.rb or config/production.rb.

# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
config.action_mailer.raise_delivery_errors = true

# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = {
  host: "nadiar.id",
  protocol: "https"
}

# Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
# config.action_mailer.smtp_settings = {
#   user_name: Rails.application.credentials.dig(:smtp, :user_name),
#   password: Rails.application.credentials.dig(:smtp, :password),
#   address: "smtp.example.com",
#   port: 587,
#   authentication: :plain
# }
config.action_mailer.delivery_method = :smtp

config.action_mailer.smtp_settings = {
  address: email-smtp.us-west-1.amazonaws.com,
  user_name: example_user_name,
  password: example_smtp_password,
  port: 587,
  authentication: :plain
}

For educational purpose, I don't use environment variables or encryption here. In production, you definitely should encrypt your secrets.

From this step, the action mailer should be able to send the email.

Sending Email

If you see this website. It has a nice email subscription form below this post or in the home page. I am using Turbo to handle the UI, and one table Subscription to store the email. 

create_table "subscriptions", force: :cascade do |t|
  t.string "email_address", null: false
  t.boolean "verified", default: false, null: false
  t.string "register_ip_address"
  t.string "register_user_agent"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.datetime "verified_at"
  t.datetime "verification_token_expires_at"
  t.index ["email_address"], name: "index_subscriptions_on_email_address", unique: true
end

Every time user sign up for subscription it will store the data to the table, but the verification flag will set it to false. The verification link is sent via email when user sign up.

The verification link unique code is generated by id using Rails.application.message_verifier.

module SubscriptionModule
  ...

  class_methods do
    def generate_verification_token(subscription_id, expires_at = 1.day.from_now)
      Rails.application.message_verifier("subscription_verification")
           .generate([ subscription_id, expires_at ],
                    purpose: :email_verification,
                    expires_at: expires_at)
    end

    def verify_token(token)
      Rails.application.message_verifier("subscription_verification")
           .verify(token, purpose: :email_verification)
    end
  end
end

And finally, we have action mailer to send the email.

class SubscriptionsMailer < ApplicationMailer
  include SubscriptionModule

  def welcome_email
    @subscription = params[:subscription]
    @verification_url = generate_verification_url(@subscription)

    mail(
      to: @subscription.email_address,
      subject: "[nadiar.id] Welcome! Please verify your email"
    )
  end

  private

    def generate_verification_url(subscription)
      token = self.class.generate_verification_token(subscription.id)
      verify_subscriptions_url(token: token)
    end
end

Here how the mailer called. The SubscriptionUseCase is called from controller.

class SubscriptionUseCase
  class Error < StandardError; end
  class VerifiedError < Error; end
  class ActiveVerificationError < Error; end

  include Rails.application.routes.url_helpers

  def initialize(email_address:, register_ip_address:, register_user_agent:)
    @email_address = email_address
    @ip_address = register_ip_address
    @user_agent = register_user_agent
    @subscription = find_subscription
  end

  def call
    validate_subscription_state
    process_subscription
  rescue Error => e
    handle_subscription_error(e)
  rescue StandardError => e
    handle_standard_error(e)
  end

  private

    attr_reader :email_address, :ip_address, :user_agent, :subscription

    def find_subscription
      Subscription.find_by(email_address: email_address)
    end

    def validate_subscription_state
      return unless subscription

      raise VerifiedError, "Email has been verified" if subscription.verified
      raise ActiveVerificationError, "Verification link has been sent to email" unless subscription.verification_expired?
    end

    def process_subscription
      ActiveRecord::Base.transaction do
        result = subscription ? update_subscription : create_subscription
        send_welcome_email(result) if result.persisted?
        result
      end
    end

    def create_subscription
      Subscription.create(subscription_attributes)
    end

    def update_subscription
      subscription.update!(subscription_attributes)
      subscription.reload
    end

    def subscription_attributes
      {
        email_address: email_address,
        register_ip_address: ip_address,
        register_user_agent: user_agent,
        verification_token_expires_at: 1.day.from_now
      }
    end

    def send_welcome_email(subscription)
      SubscriptionsMailer.with(subscription: subscription)
                        .welcome_email
                        .deliver_later
    rescue StandardError => e
      log_error("Failed to send welcome email", e)
      notify_error(e)
    end

    def handle_subscription_error(error)
      log_error("Subscription validation failed", error)
      build_error_response(error.message)
    end

    def handle_standard_error(error)
      log_error("Failed to process subscription", error)
      build_error_response("Failed to process subscription")
    end

    def build_error_response(message)
      (subscription || Subscription.new(email_address: email_address)).tap do |s|
        s.errors.add(:base, message)
      end
    end

    def log_error(message, error)
      Rails.logger.error "#{message}: #{error.message}"
    end

    def notify_error(error)
      ExceptionNotifier.notify_exception(error) if defined?(ExceptionNotifier)
    end
end

And that's it. Email delivered to the user.

If you'd like to see this system in action, subscribe to our newsletter below this post. Your confirmation email will be delivered through this exact AWS SES setup!

Subscribe

Want to stay updated on my latest projects, web dev tips, and the occasional gardening triumph? Subscribe to my newsletter!