IMEOS
  1. Home
  2. Work
  3. Contact
  4. Blog

Im Obstgarten 7
8596 Scherzingen
Switzerland

+41 79 786 10 11
(CET/CEST, Mo-Fr, 09:00 - 18:00)
io@imeos.com

IMEOS on GitHub
RailsAWS

Amazon SES for transactional emails

Mar 3, 2025
15 minutes read

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:

  1. Create AWS Account at https://aws.amazon.com

  2. Navigate to SES Console in your preferred region (e.g., eu-central-1 for Europe)

  3. 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”
  4. 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:

  1. In SES Console, click “Request production access”
  2. Describe your use case (e.g., “Transactional emails for SaaS application”)
  3. Specify daily sending volume estimate
  4. 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:

  1. In SES Console, go to “SMTP Settings”
  2. Click “Create SMTP Credentials”
  3. Enter a name (e.g., your-production-smtp)
  4. 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:

  1. Go to “Configuration Sets” → “Create Configuration Set”
  2. Name it (e.g., your-configuration-set)
  3. 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:

  1. Email arrives
  2. Headers show it came from SES
  3. 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:

  1. Create SNS Topics in AWS Console:

    • ses-bounces
    • ses-complaints
  2. Subscribe your application via HTTPS endpoint or email

  3. 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.

Further Reading

  • Amazon SES Documentation
  • SES SMTP Interface
  • Ahoy Email GitHub
  • Rails ActionMailer Guide
  • GDPR Email Marketing Compliance

  • Privacy
  • Imprint
IMEOS