Reliable, scalable, and cost-effective email delivery with tracking and monitoring
Transactional emails are critical infrastructure for any web application. User registrations, password resets, notifications—these emails must be delivered reliably and quickly. For years, many Rails apps relied on basic SMTP providers or Heroku add-ons, but these solutions often come with limitations in deliverability, monitoring, and cost.
This post details how to use Amazon Simple Email Service (SES) for transactional emails, including SMTP configuration, email tracking with Ahoy Email, and building an admin dashboard for monitoring sent emails.
Why Amazon SES?
Cost-Effective: First 62,000 emails per month are free when sending from EC2 or other AWS services. After that, $0.10 per 1,000 emails. Compare this to typical SMTP add-ons charging $10-50/month for similar volumes.
High Deliverability: AWS maintains strong sender reputation across all major email providers. Built-in spam filtering and bounce handling help maintain good standing.
Scalability: Handle everything from hundreds to millions of emails without changing your code. SES scales automatically.
Monitoring and Analytics: Detailed sending statistics, bounce and complaint tracking, and integration with CloudWatch for metrics.
Reliability: 99.9% uptime SLA. AWS’s global infrastructure ensures emails get through even during provider issues.
Compliance: Meets GDPR, HIPAA, and other regulatory requirements out of the box.
The Challenge
Traditional SMTP services work fine at small scale but present issues as you grow:
Limited Visibility: Basic SMTP gives you no insight into delivery rates, bounces, or complaints.
Cost Scaling: Many SMTP add-ons charge per-email tiers that become expensive quickly.
Deliverability Issues: Shared IP pools mean your emails might be affected by other senders’ bad behavior.
No Analytics: Understanding email performance requires third-party services or manual tracking.
Amazon SES addresses all these challenges while remaining simple to integrate.
Implementation: Part 1 - Amazon SES Setup
Step 1: Create an AWS Account and Verify Domain
First, set up your AWS account and verify your sending domain:
-
Create AWS Account at https://aws.amazon.com
-
Navigate to SES Console in your preferred region (e.g., eu-central-1 for Europe)
-
Verify Your Domain:
- Go to “Verified Identities” → “Create Identity”
- Choose “Domain”
- Enter your domain (e.g.,
yourdomain.com) - Choose DKIM signing (recommended)
- Click “Create Identity”
-
Add DNS Records: SES provides DKIM and verification records. Add these to your DNS:
# Verification TXT record
_amazonses.yourdomain.com. TXT "verification-token-here"
# DKIM CNAME records (3 records)
token1._domainkey.yourdomain.com. CNAME token1.dkim.amazonses.com
token2._domainkey.yourdomain.com. CNAME token2.dkim.amazonses.com
token3._domainkey.yourdomain.com. CNAME token3.dkim.amazonses.com
Wait for DNS propagation (usually 10-30 minutes). SES will automatically verify once records are detected.
Step 2: Request Production Access
By default, SES accounts start in “sandbox mode” with limitations:
- Can only send to verified email addresses
- Maximum 200 emails per 24 hours
- Maximum 1 email per second
Request production access:
- In SES Console, click “Request production access”
- Describe your use case (e.g., “Transactional emails for SaaS application”)
- Specify daily sending volume estimate
- Describe how you handle bounces and complaints
AWS typically approves within 24 hours for legitimate use cases.
Step 3: Create SMTP Credentials
Generate SMTP credentials for your application:
- In SES Console, go to “SMTP Settings”
- Click “Create SMTP Credentials”
- Enter a name (e.g.,
your-production-smtp) - Download and save the credentials securely
You’ll receive:
- SMTP Username
- SMTP Password
- SMTP Endpoint (e.g.,
email-smtp.eu-central-1.amazonaws.com)
Important: Store these credentials securely. You cannot retrieve the password later.
Step 4: Configure a Configuration Set (Optional but Recommended)
Configuration sets enable detailed monitoring and event tracking:
- Go to “Configuration Sets” → “Create Configuration Set”
- Name it (e.g.,
your-configuration-set) - Add event destinations:
- Bounce tracking: CloudWatch or SNS
- Complaint tracking: CloudWatch or SNS
- Delivery tracking: CloudWatch (optional)
This allows you to track bounces and complaints for list hygiene.
Implementation: Part 2 - Rails Configuration
Step 1: Store SES Credentials
Add your SES credentials to environment variables. On Heroku:
heroku config:set SES_SMTP_USERNAME=your-username
heroku config:set SES_SMTP_PASSWORD=your-password
heroku config:set SES_SMTP_ADDRESS=email-smtp.eu-central-1.amazonaws.com
For local development, use Rails credentials or environment variables:
# .env (with dotenv gem)
SES_SMTP_USERNAME=your-username
SES_SMTP_PASSWORD=your-password
SES_SMTP_ADDRESS=email-smtp.eu-central-1.amazonaws.com
Step 2: Configure ActionMailer for SES
Update your production environment configuration:
config/environments/production.rb:
Rails.application.configure do
# Set delivery method to SMTP
config.action_mailer.delivery_method = :smtp
# Configure default URL options for email links
config.action_mailer.default_url_options = {
host: 'www.yourdomain.com',
protocol: 'https'
}
# Amazon SES SMTP settings
config.action_mailer.smtp_settings = {
user_name: ENV.fetch('SES_SMTP_USERNAME', nil),
password: ENV.fetch('SES_SMTP_PASSWORD', nil),
address: ENV.fetch('SES_SMTP_ADDRESS', nil),
port: 587,
authentication: :login,
enable_starttls_auto: true
}
# Add SES Configuration Set header for monitoring
ActionMailer::Base.default headers: {
'X-SES-CONFIGURATION-SET' => 'your-configuration-set'
}
end
Key Configuration Details:
- Port 587: Standard SMTP submission port with STARTTLS
- authentication: :login: Required by SES
- enable_starttls_auto: true: Ensures encrypted connection
- X-SES-CONFIGURATION-SET: Links emails to your configuration set for tracking
Step 3: Create a Test Mailer
Create a simple mailer to test the configuration:
app/mailers/test_mailer.rb:
class TestMailer < ApplicationMailer
def ping
mail(
to: 'your-email@example.com',
subject: 'SES Test Email'
) do |format|
format.html { render html: '<h1>SES is working!</h1>'.html_safe }
format.text { render plain: 'SES is working!' }
end
end
end
Test from Rails console:
# In production console
TestMailer.ping.deliver_now
Check your inbox and verify:
- Email arrives
- Headers show it came from SES
- DKIM signature is present (check “Show original” in Gmail)
Implementation: Part 3 - Email Tracking with Ahoy Email
Knowing that emails were sent is good. Knowing when, to whom, and which ones is better. Ahoy Email provides simple, database-backed email tracking.
Step 1: Add Ahoy Email Gem
Add to your Gemfile:
gem 'ahoy_email', '~> 2.0'
Run bundle install.
Step 2: Generate and Run Migration
Create the tracking table:
bin/rails generate ahoy:messages
bin/rails db:migrate
This creates the ahoy_messages table:
create_table :ahoy_messages do |t|
t.references :user, polymorphic: true
t.string :to, index: true
t.string :mailer
t.text :subject
t.datetime :sent_at
end
Step 3: Configure Ahoy Email
Create an initializer:
config/initializers/ahoy_email.rb:
# Track all sent emails
AhoyEmail.default_options[:message] = true
# Disable click and open tracking (privacy-friendly)
AhoyEmail.default_options[:track_clicks] = false
AhoyEmail.default_options[:track_opens] = false
Why disable click/open tracking?
- Privacy: Open tracking uses invisible pixels; click tracking rewrites URLs
- GDPR Compliance: Tracking without consent can violate regulations
- Simplicity: We only need to know emails were sent, not user behavior
Step 4: Add Ahoy to Mailers
Add tracking to your application mailers:
app/mailers/application_mailer.rb:
class ApplicationMailer < ActionMailer::Base
# Track emails with Ahoy
track message: true
default from: 'notifications@yourdomain.com'
layout 'mailer'
end
For more specific tracking, override in individual mailers:
class ReportsMailer < ApplicationMailer
has_history user: -> { @user }
def daily_report(user)
@user = user
mail(to: @user.email, subject: 'Daily Report')
end
end
Implementation: Part 4 - Email Admin Dashboard
Build an admin interface to monitor sent emails:
Step 1: Create Ahoy::Message Model
app/models/ahoy/message.rb:
class Ahoy::Message < ApplicationRecord
belongs_to :user, polymorphic: true, optional: true
# Scopes for filtering
scope :recent, -> { order(sent_at: :desc) }
scope :last_days, ->(days) { where('sent_at >= ?', days.days.ago) }
scope :by_mailer, ->(mailer) { where(mailer: mailer) }
scope :to_user, ->(user) { where(user: user) }
end
Step 2: Add Controller Action
app/controllers/administration/dashboard_controller.rb:
class Administration::DashboardController < ApplicationController
before_action :authenticate_admin!
def emails
@users = User.order(:email)
# Build query based on filters
emails_query = Ahoy::Message.last_days(10).recent
if params[:user_id].present?
emails_query = emails_query.to_user(User.find(params[:user_id]))
end
if params[:mailer].present?
emails_query = emails_query.by_mailer(params[:mailer])
end
# Paginate results
@pagy, @emails = pagy(emails_query, items: 50)
# Calculate statistics
@email_stats = {
delivered: @emails.count,
total_last_10_days: Ahoy::Message.last_days(10).count
}
end
end
Step 3: Create Dashboard View
app/views/administration/dashboard/emails.html.erb:
<div class="page-header">
<h1>Email Dashboard</h1>
</div>
<div class="card">
<div class="card-header">
<h5>Search & Filter</h5>
</div>
<div class="card-body">
<%= form_with url: administration_emails_path, method: :get, local: true do |f| %>
<div class="form-grid">
<div class="form-group">
<%= f.label :user_id, "User" %>
<%= f.collection_select :user_id, @users, :id, :full_name,
{ include_blank: "All users", selected: params[:user_id] },
{ class: 'form-control' } %>
</div>
<div class="form-group">
<%= f.label :mailer, "Mailer" %>
<%= f.select :mailer,
options_for_select(
Ahoy::Message.distinct.pluck(:mailer).compact,
params[:mailer]
),
{ include_blank: "All mailers" },
{ class: 'form-control' } %>
</div>
</div>
<div class="form-actions">
<%= f.submit "Search", class: 'btn btn-primary' %>
<%= link_to "Reset", administration_emails_path, class: 'btn btn-secondary' %>
</div>
<% end %>
</div>
</div>
<div class="card">
<div class="card-header">
<h5>Email Statistics (Last 10 Days)</h5>
</div>
<div class="card-body">
<div class="stats-grid">
<div class="stat-card">
<h3><%= @email_stats[:total_last_10_days] %></h3>
<p>Total Emails Sent</p>
</div>
<div class="stat-card stat-success">
<h3><%= @email_stats[:delivered] %></h3>
<p>In Current Filter</p>
</div>
<div class="stat-card stat-info">
<h3><%= @emails.first&.sent_at&.to_date || '-' %></h3>
<p>Latest Email Date</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5>Sent Emails</h5>
</div>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Sent At</th>
<th>Subject</th>
<th>Recipient</th>
<th>User</th>
<th>Mailer</th>
</tr>
</thead>
<tbody>
<% @emails.each do |email| %>
<tr>
<td><%= l(email.sent_at, format: :short) if email.sent_at %></td>
<td><%= email.subject %></td>
<td><%= email.to %></td>
<td><%= email.user&.full_name || '-' %></td>
<td><%= email.mailer || '-' %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<div class="pagination">
<%# Use your preferred pagination helper (pagy, kaminari, will_paginate, etc.) %>
<%# Example: <%= pagy_nav(@pagy) %>
<%# Example: <%= paginate @emails %>
</div>
Step 5: Add Cleanup Task
Prevent unbounded growth of the tracking table:
lib/tasks/email_cleanup.rake:
namespace :email do
desc 'Clean up old email tracking records'
task cleanup: :environment do
# Delete emails older than 90 days
deleted = Ahoy::Message.where('sent_at < ?', 90.days.ago).delete_all
puts "Deleted #{deleted} old email records"
end
end
Schedule this task to run periodically (e.g., via GoodJob cron):
# config/application.rb
config.good_job.cron = {
email_cleanup: {
class: 'RakeCleanupEmailJob',
cron: '0 3 * * 0', # Weekly on Sunday at 3 AM
description: 'Clean up old email tracking records'
}
}
Privacy and GDPR Compliance
Email tracking raises privacy concerns. Here’s how to stay compliant:
1. Update Privacy Policy
Disclose email tracking in your privacy policy:
app/views/pages/privacy_policy.html:
<h3>Email Communications</h3>
<p>
When sending emails, you can track basic delivery information including:
- Email address
- Subject line
- Timestamp
- Associated user account
</p>
<p>
We would NOT RECOMMEND to track email opens or link clicks in production. This data is only appropriate for
system monitoring and troubleshooting email delivery issues.
</p>
<p>
Email tracking data is retained for 90 days and then automatically deleted.
</p>
2. Disable User Tracking Where Appropriate
For some emails, you may not want user association:
class PublicNotificationMailer < ApplicationMailer
# Track the email but not the user
track message: true, user: false
def announcement
mail(to: 'all@example.com', subject: 'Announcement')
end
end
3. Provide Data Access
Allow users to see their tracked emails:
# In user account settings
def my_emails
@emails = Ahoy::Message.to_user(current_user).recent.limit(50)
end
Monitoring and Alerts
SES Bounce and Complaint Handling
Configure SNS topics to receive bounce/complaint notifications:
-
Create SNS Topics in AWS Console:
ses-bouncesses-complaints
-
Subscribe your application via HTTPS endpoint or email
-
Process notifications in your app:
# app/controllers/webhooks/ses_controller.rb
class Webhooks::SesController < ApplicationController
skip_before_action :verify_authenticity_token
def notification
message = JSON.parse(request.body.read)
case message['notificationType']
when 'Bounce'
handle_bounce(message)
when 'Complaint'
handle_complaint(message)
end
head :ok
end
private
def handle_bounce(message)
bounced_recipients = message['bounce']['bouncedRecipients']
bounced_recipients.each do |recipient|
email = recipient['emailAddress']
# Mark email as invalid
User.where(email: email).update_all(email_valid: false)
# Log for admin review
Rails.logger.warn "Email bounced: #{email}"
end
end
def handle_complaint(message)
complained_recipients = message['complaint']['complainedRecipients']
complained_recipients.each do |recipient|
email = recipient['emailAddress']
# Unsubscribe user from emails
User.where(email: email).update_all(email_notifications: false)
# Alert admins
AdminMailer.spam_complaint(email).deliver_later
end
end
end
CloudWatch Metrics
Monitor SES metrics in CloudWatch:
- Send rate
- Bounce rate
- Complaint rate
- Delivery rate
Set up alarms for anomalies (e.g., bounce rate > 5%).
Conclusion
Migrating to Amazon SES provides enterprise-grade email delivery at a fraction of the cost of traditional SMTP services. Combined with Ahoy Email for tracking and a custom admin dashboard, you gain complete visibility into your transactional email system.
The setup is straightforward, the costs are minimal, and the reliability is excellent.
By tracking sent emails with Ahoy, you can troubleshoot delivery issues, monitor system health, and provide transparency to users—all while maintaining GDPR compliance and respecting user privacy.