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!