home / skills / thibautbaissac / rails_ai_agents / action-mailer-patterns

action-mailer-patterns skill

/skills/action-mailer-patterns

This skill helps Rails developers implement robust transactional emails with Action Mailer using TDD, previews, and background delivery.

npx playbooks add skill thibautbaissac/rails_ai_agents --skill action-mailer-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.7 KB
---
name: action-mailer-patterns
description: Implements transactional emails with Action Mailer and TDD. Use when creating email templates, notification emails, password resets, email previews, or when user mentions mailer, email, notifications, or transactional emails.
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---

# Action Mailer Patterns for Rails 8

## Overview

Action Mailer handles transactional emails in Rails:
- HTML and text email templates
- Layouts for consistent styling
- Previews for development
- Background delivery via Active Job
- Internationalized emails

## Quick Start

```bash
# Generate mailer
bin/rails generate mailer User welcome password_reset

# This creates:
# - app/mailers/user_mailer.rb
# - app/views/user_mailer/welcome.html.erb
# - app/views/user_mailer/welcome.text.erb
# - spec/mailers/user_mailer_spec.rb (if using RSpec)
```

## Project Structure

```
app/
├── mailers/
│   ├── application_mailer.rb    # Base mailer
│   └── user_mailer.rb
├── views/
│   ├── layouts/
│   │   └── mailer.html.erb      # Email layout
│   └── user_mailer/
│       ├── welcome.html.erb
│       ├── welcome.text.erb
│       ├── password_reset.html.erb
│       └── password_reset.text.erb
spec/
├── mailers/
│   ├── user_mailer_spec.rb
│   └── previews/
│       └── user_mailer_preview.rb
```

## TDD Workflow

```
Mailer Progress:
- [ ] Step 1: Write mailer spec (RED)
- [ ] Step 2: Run spec (fails)
- [ ] Step 3: Create mailer method
- [ ] Step 4: Create email templates
- [ ] Step 5: Run spec (GREEN)
- [ ] Step 6: Create preview
- [ ] Step 7: Test delivery integration
```

## Configuration

### Base Setup

```ruby
# config/environments/development.rb
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }

# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.default_url_options = { host: "example.com" }
config.action_mailer.smtp_settings = {
  address: "smtp.example.com",
  port: 587,
  user_name: Rails.application.credentials.smtp[:user_name],
  password: Rails.application.credentials.smtp[:password],
  authentication: "plain",
  enable_starttls_auto: true
}
```

### Application Mailer

```ruby
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "[email protected]"
  layout "mailer"

  # Shared helper for all mailers
  helper_method :app_name

  private

  def app_name
    Rails.application.class.module_parent_name
  end
end
```

## Testing Mailers

### Mailer Spec

```ruby
# spec/mailers/user_mailer_spec.rb
require "rails_helper"

RSpec.describe UserMailer, type: :mailer do
  describe "#welcome" do
    let(:user) { create(:user, email_address: "[email protected]", name: "John") }
    let(:mail) { described_class.welcome(user) }

    it "renders the headers" do
      expect(mail.subject).to eq(I18n.t("user_mailer.welcome.subject"))
      expect(mail.to).to eq(["[email protected]"])
      expect(mail.from).to eq(["[email protected]"])
    end

    it "renders the HTML body" do
      expect(mail.html_part.body.to_s).to include("John")
      expect(mail.html_part.body.to_s).to include("Welcome")
    end

    it "renders the text body" do
      expect(mail.text_part.body.to_s).to include("John")
      expect(mail.text_part.body.to_s).to include("Welcome")
    end

    it "includes login link" do
      expect(mail.html_part.body.to_s).to include(new_session_url)
    end
  end

  describe "#password_reset" do
    let(:user) { create(:user) }
    let(:token) { "reset-token-123" }
    let(:mail) { described_class.password_reset(user, token) }

    it "renders the headers" do
      expect(mail.subject).to eq(I18n.t("user_mailer.password_reset.subject"))
      expect(mail.to).to eq([user.email_address])
    end

    it "includes reset link with token" do
      expect(mail.html_part.body.to_s).to include(token)
    end

    it "expires link information" do
      expect(mail.html_part.body.to_s).to include("24 hours")
    end
  end
end
```

### Testing Delivery

```ruby
# spec/services/user_registration_service_spec.rb
RSpec.describe UserRegistrationService do
  describe "#call" do
    it "sends welcome email" do
      expect {
        described_class.new.call(user_params)
      }.to have_enqueued_mail(UserMailer, :welcome)
    end
  end
end

# Integration test
RSpec.describe "User Registration", type: :request do
  it "sends welcome email after registration" do
    expect {
      post registrations_path, params: valid_params
    }.to have_enqueued_mail(UserMailer, :welcome)
  end
end
```

## Mailer Implementation

### Basic Mailer

```ruby
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  def welcome(user)
    @user = user
    @login_url = new_session_url

    mail(
      to: @user.email_address,
      subject: t(".subject")
    )
  end

  def password_reset(user, token)
    @user = user
    @token = token
    @reset_url = edit_password_url(token: token)
    @expires_in = "24 hours"

    mail(
      to: @user.email_address,
      subject: t(".subject")
    )
  end
end
```

### Mailer with Attachments

```ruby
class ReportMailer < ApplicationMailer
  def monthly_report(user, report)
    @user = user
    @report = report

    # Attach file
    attachments["report-#{Date.current}.pdf"] = report.to_pdf

    # Attach inline image
    attachments.inline["logo.png"] = File.read(Rails.root.join("app/assets/images/logo.png"))

    mail(to: @user.email_address, subject: t(".subject"))
  end
end
```

### Mailer with Dynamic Sender

```ruby
class NotificationMailer < ApplicationMailer
  def notify(recipient, sender, message)
    @recipient = recipient
    @sender = sender
    @message = message

    mail(
      to: @recipient.email_address,
      from: "#{@sender.name} <[email protected]>",
      reply_to: @sender.email_address,
      subject: t(".subject", sender: @sender.name)
    )
  end
end
```

## Email Templates

### HTML Template

```erb
<%# app/views/user_mailer/welcome.html.erb %>
<h1><%= t(".greeting", name: @user.name) %></h1>

<p><%= t(".intro") %></p>

<p><%= t(".getting_started") %></p>

<ul>
  <li><%= t(".step_1") %></li>
  <li><%= t(".step_2") %></li>
  <li><%= t(".step_3") %></li>
</ul>

<p>
  <%= link_to t(".login_button"), @login_url, class: "button" %>
</p>

<p><%= t(".help_text_html", support_email: mail_to("[email protected]")) %></p>
```

### Text Template

```erb
<%# app/views/user_mailer/welcome.text.erb %>
<%= t(".greeting", name: @user.name) %>

<%= t(".intro") %>

<%= t(".getting_started") %>

* <%= t(".step_1") %>
* <%= t(".step_2") %>
* <%= t(".step_3") %>

<%= t(".login_prompt") %>: <%= @login_url %>

<%= t(".help_text", support_email: "[email protected]") %>
```

### Email Layout

```erb
<%# app/views/layouts/mailer.html.erb %>
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <meta name="viewport" content="width=device-width">
    <style>
      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        line-height: 1.6;
        color: #333;
        max-width: 600px;
        margin: 0 auto;
        padding: 20px;
      }
      .button {
        display: inline-block;
        padding: 12px 24px;
        background-color: #0066cc;
        color: #ffffff;
        text-decoration: none;
        border-radius: 4px;
      }
      .footer {
        margin-top: 40px;
        padding-top: 20px;
        border-top: 1px solid #eee;
        font-size: 12px;
        color: #666;
      }
    </style>
  </head>
  <body>
    <%= yield %>

    <div class="footer">
      <p><%= t("mailer.footer.company_name") %></p>
      <p><%= t("mailer.footer.address") %></p>
    </div>
  </body>
</html>
```

## Previews

### Creating Previews

```ruby
# spec/mailers/previews/user_mailer_preview.rb
# OR test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
  def welcome
    user = User.first || FactoryBot.build(:user, name: "Preview User")
    UserMailer.welcome(user)
  end

  def password_reset
    user = User.first || FactoryBot.build(:user)
    UserMailer.password_reset(user, "preview-token-123")
  end
end
```

Access previews at: `http://localhost:3000/rails/mailers`

### Preview with Different States

```ruby
class OrderMailerPreview < ActionMailer::Preview
  def confirmation
    order = Order.last || build_preview_order
    OrderMailer.confirmation(order)
  end

  def confirmation_with_discount
    order = build_preview_order
    order.discount_cents = 1000
    OrderMailer.confirmation(order)
  end

  def confirmation_multiple_items
    order = build_preview_order
    3.times { order.line_items.build(product: Product.first, quantity: 2) }
    OrderMailer.confirmation(order)
  end

  private

  def build_preview_order
    Order.new(
      user: User.first,
      total_cents: 5000,
      created_at: Time.current
    )
  end
end
```

## Internationalization

### Locale Files

```yaml
# config/locales/mailers/en.yml
en:
  user_mailer:
    welcome:
      subject: "Welcome to Our App!"
      greeting: "Hello %{name}!"
      intro: "Thanks for signing up."
      getting_started: "Here's how to get started:"
      step_1: "Complete your profile"
      step_2: "Explore features"
      step_3: "Connect with others"
      login_button: "Log In Now"
      login_prompt: "Log in here"
      help_text: "Questions? Email us at %{support_email}"
      help_text_html: "Questions? Email us at %{support_email}"
    password_reset:
      subject: "Reset Your Password"

  mailer:
    footer:
      company_name: "Example Company"
      address: "123 Main St, City, Country"

# config/locales/mailers/fr.yml
fr:
  user_mailer:
    welcome:
      subject: "Bienvenue sur notre application !"
      greeting: "Bonjour %{name} !"
      # ...
```

### Sending Localized Emails

```ruby
class UserMailer < ApplicationMailer
  def welcome(user)
    @user = user
    I18n.with_locale(user.locale || I18n.default_locale) do
      mail(to: @user.email_address, subject: t(".subject"))
    end
  end
end
```

## Delivery Methods

### Immediate Delivery (Avoid in production)

```ruby
UserMailer.welcome(user).deliver_now
```

### Background Delivery (Preferred)

```ruby
# Uses Active Job
UserMailer.welcome(user).deliver_later

# With options
UserMailer.welcome(user).deliver_later(wait: 5.minutes)
UserMailer.welcome(user).deliver_later(wait_until: Date.tomorrow.noon)
UserMailer.welcome(user).deliver_later(queue: :mailers)
```

### From Services

```ruby
class UserRegistrationService
  def call(params)
    user = User.create!(params)
    UserMailer.welcome(user).deliver_later
    success(user)
  end
end
```

## Common Patterns

### Conditional Emails

```ruby
class NotificationMailer < ApplicationMailer
  def daily_digest(user)
    @user = user
    @notifications = user.notifications.unread.today

    # Don't send if nothing to report
    return if @notifications.empty?

    mail(to: @user.email_address, subject: t(".subject"))
  end
end
```

### Bulk Emails with Batching

```ruby
class NewsletterJob < ApplicationJob
  def perform
    User.subscribed.find_each(batch_size: 100) do |user|
      NewsletterMailer.weekly(user).deliver_later
    end
  end
end
```

### Email Callbacks

```ruby
class ApplicationMailer < ActionMailer::Base
  after_action :log_delivery

  private

  def log_delivery
    Rails.logger.info("Sending #{action_name} to #{mail.to}")
  end
end
```

## Checklist

- [ ] Mailer spec written first (RED)
- [ ] Mailer method created
- [ ] HTML template created
- [ ] Text template created
- [ ] Uses I18n for all text
- [ ] Preview created
- [ ] Uses `deliver_later` (not `deliver_now`)
- [ ] Email layout styled
- [ ] All specs GREEN

Overview

This skill implements transactional emails in Rails 8.1 using Action Mailer with a TDD-first workflow. It provides patterns for mailer structure, templates, previews, internationalization, background delivery, and testing so you can ship reliable notification emails and password resets. Use it to enforce consistent, testable email behavior across your app.

How this skill works

The skill scaffolds mailers and organizes views, layouts, and previews. It emphasizes writing mailer specs first (red/green/refactor), creating both HTML and text templates, and using deliver_later for background delivery. It also covers attachments, dynamic senders, I18n, and enqueuing assertions in specs.

When to use it

  • Creating notification emails, welcome flows, or password resets
  • Building email templates with both HTML and plain-text parts
  • Adding previews for development and QA
  • Ensuring mailers are covered by automated tests and TDD
  • Delivering emails via background jobs (Active Job)

Best practices

  • Write the mailer spec first and follow red/green/refactor
  • Provide both HTML and text templates for deliverability
  • Use deliver_later to enqueue emails, not deliver_now in production
  • Keep shared settings in ApplicationMailer (default from, layout, helpers)
  • Localize all copy with I18n and test locale-specific behavior
  • Use previews to iterate on content and different message states

Example use cases

  • Welcome email: spec that asserts subject, headers, and body includes user name and login link
  • Password reset: mailer that includes tokenized link and expiry text, with specs validating token presence
  • Monthly report: mailer attaching a generated PDF and inline logo for recipients
  • Notification with dynamic sender: personalize from/reply_to and subject interpolation
  • Newsletter batching: job that iterates subscribers with find_each and enqueues individual mails

FAQ

How do I test that an email was enqueued?

Use have_enqueued_mail in your service or request specs to assert that a given mailer and action were enqueued (e.g., expect { action }.to have_enqueued_mail(UserMailer, :welcome)).

Should I include both HTML and text templates?

Yes. Providing both improves deliverability and ensures recipients who prefer plain text still receive a readable message.