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
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!