action-mailer-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Action Mailer Patterns for Rails 8

Rails 8 中的 Action Mailer 实践模式

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
Action Mailer 负责处理 Rails 中的事务性邮件:
  • HTML 和纯文本邮件模板
  • 用于统一样式的布局
  • 开发环境下的邮件预览
  • 通过 Active Job 实现后台发送
  • 多语言国际化邮件

Quick Start

快速开始

bash
undefined
bash
undefined

Generate mailer

生成邮件发送器

bin/rails generate mailer User welcome password_reset
bin/rails generate mailer User welcome password_reset

This creates:

这会生成以下文件:

- app/mailers/user_mailer.rb

- app/mailers/user_mailer.rb

- app/views/user_mailer/welcome.html.erb

- app/views/user_mailer/welcome.html.erb

- app/views/user_mailer/welcome.text.erb

- app/views/user_mailer/welcome.text.erb

- spec/mailers/user_mailer_spec.rb (if using RSpec)

- spec/mailers/user_mailer_spec.rb (如果使用 RSpec)

undefined
undefined

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
app/
├── mailers/
│   ├── application_mailer.rb    # 基础邮件发送器
│   └── user_mailer.rb
├── views/
│   ├── layouts/
│   │   └── mailer.html.erb      # 邮件布局
│   └── 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

TDD 工作流

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
邮件发送器开发进度:
- [ ] 步骤1:编写邮件发送器测试用例(失败状态)
- [ ] 步骤2:运行测试用例(执行失败)
- [ ] 步骤3:创建邮件发送器方法
- [ ] 步骤4:创建邮件模板
- [ ] 步骤5:运行测试用例(通过状态)
- [ ] 步骤6:创建邮件预览
- [ ] 步骤7:测试邮件发送集成

Configuration

配置

Base Setup

基础设置

ruby
undefined
ruby
undefined

config/environments/development.rb

config/environments/development.rb

config.action_mailer.delivery_method = :letter_opener config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
config.action_mailer.delivery_method = :letter_opener config.action_mailer.default_url_options = { host: "localhost", port: 3000 }

config/environments/production.rb

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 }
undefined
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 }
undefined

Application Mailer

应用级邮件发送器

ruby
undefined
ruby
undefined

app/mailers/application_mailer.rb

app/mailers/application_mailer.rb

class ApplicationMailer < ActionMailer::Base default from: "noreply@example.com" layout "mailer"

Shared helper for all mailers

helper_method :app_name
private
def app_name Rails.application.class.module_parent_name end end
undefined
class ApplicationMailer < ActionMailer::Base default from: "noreply@example.com" layout "mailer"

所有邮件发送器共享的辅助方法

helper_method :app_name
private
def app_name Rails.application.class.module_parent_name end end
undefined

Testing Mailers

测试邮件发送器

Mailer Spec

邮件发送器测试用例

ruby
undefined
ruby
undefined

spec/mailers/user_mailer_spec.rb

spec/mailers/user_mailer_spec.rb

require "rails_helper"
RSpec.describe UserMailer, type: :mailer do describe "#welcome" do let(:user) { create(:user, email_address: "user@example.com", 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(["user@example.com"])
  expect(mail.from).to eq(["noreply@example.com"])
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
undefined
require "rails_helper"
RSpec.describe UserMailer, type: :mailer do describe "#welcome" do let(:user) { create(:user, email_address: "user@example.com", name: "John") } let(:mail) { described_class.welcome(user) }
it "渲染邮件头" do
  expect(mail.subject).to eq(I18n.t("user_mailer.welcome.subject"))
  expect(mail.to).to eq(["user@example.com"])
  expect(mail.from).to eq(["noreply@example.com"])
end

it "渲染HTML正文" do
  expect(mail.html_part.body.to_s).to include("John")
  expect(mail.html_part.body.to_s).to include("Welcome")
end

it "渲染纯文本正文" do
  expect(mail.text_part.body.to_s).to include("John")
  expect(mail.text_part.body.to_s).to include("Welcome")
end

it "包含登录链接" 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 "渲染邮件头" do
  expect(mail.subject).to eq(I18n.t("user_mailer.password_reset.subject"))
  expect(mail.to).to eq([user.email_address])
end

it "包含带令牌的重置链接" do
  expect(mail.html_part.body.to_s).to include(token)
end

it "包含链接过期信息" do
  expect(mail.html_part.body.to_s).to include("24 hours")
end
end end
undefined

Testing Delivery

测试邮件发送

ruby
undefined
ruby
undefined

spec/services/user_registration_service_spec.rb

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
RSpec.describe UserRegistrationService do describe "#call" do it "发送欢迎邮件" 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
undefined
RSpec.describe "User Registration", type: :request do it "注册后发送欢迎邮件" do expect { post registrations_path, params: valid_params }.to have_enqueued_mail(UserMailer, :welcome) end end
undefined

Mailer Implementation

邮件发送器实现

Basic Mailer

基础邮件发送器

ruby
undefined
ruby
undefined

app/mailers/user_mailer.rb

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

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
ruby
class ReportMailer < ApplicationMailer
  def monthly_report(user, report)
    @user = user
    @report = report

    # 附加文件
    attachments["report-#{Date.current}.pdf"] = report.to_pdf

    # 附加内嵌图片
    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} <notifications@example.com>",
      reply_to: @sender.email_address,
      subject: t(".subject", sender: @sender.name)
    )
  end
end
ruby
class NotificationMailer < ApplicationMailer
  def notify(recipient, sender, message)
    @recipient = recipient
    @sender = sender
    @message = message

    mail(
      to: @recipient.email_address,
      from: "#{@sender.name} <notifications@example.com>",
      reply_to: @sender.email_address,
      subject: t(".subject", sender: @sender.name)
    )
  end
end

Email Templates

邮件模板

HTML Template

HTML 模板

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("support@example.com")) %></p>
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("support@example.com")) %></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: "support@example.com") %>
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: "support@example.com") %>

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>
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
undefined
ruby
undefined

spec/mailers/previews/user_mailer_preview.rb

spec/mailers/previews/user_mailer_preview.rb

OR test/mailers/previews/user_mailer_preview.rb

或 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`
class UserMailerPreview < ActionMailer::Preview def welcome user = User.first || FactoryBot.build(:user, name: "预览用户") UserMailer.welcome(user) end
def password_reset user = User.first || FactoryBot.build(:user) UserMailer.password_reset(user, "preview-token-123") end end

访问预览地址:`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
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
undefined
yaml
undefined

config/locales/mailers/en.yml

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

config/locales/mailers/fr.yml

fr: user_mailer: welcome: subject: "Bienvenue sur notre application !" greeting: "Bonjour %{name} !" # ...
undefined
fr: user_mailer: welcome: subject: "Bienvenue sur notre application !" greeting: "Bonjour %{name} !" # ...
undefined

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
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
ruby
UserMailer.welcome(user).deliver_now

Background Delivery (Preferred)

后台发送(推荐使用)

ruby
undefined
ruby
undefined

Uses Active Job

使用 Active Job

UserMailer.welcome(user).deliver_later
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)
undefined
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)
undefined

From Services

从服务中调用

ruby
class UserRegistrationService
  def call(params)
    user = User.create!(params)
    UserMailer.welcome(user).deliver_later
    success(user)
  end
end
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
ruby
class NotificationMailer < ApplicationMailer
  def daily_digest(user)
    @user = user
    @notifications = user.notifications.unread.today

    # 无内容时不发送
    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
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
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
  • 先编写邮件发送器测试用例(失败状态)
  • 创建邮件发送器方法
  • 创建HTML模板
  • 创建纯文本模板
  • 所有文本使用国际化I18n
  • 创建邮件预览
  • 使用
    deliver_later
    (而非
    deliver_now
  • 邮件布局已样式化
  • 所有测试用例通过(成功状态)