12 任务H 发送电子邮件

优质
小牛编辑
133浏览
2023-12-01

现在我们已经拥有了响应用户请求的网站,并且还可以对单独商品进行定期订单推送的功能。但它的功能应该不止于此,我们需要在事件发生时向指定用户发送特定信息的功能。当异常发生时也可以通过同样的方式通知管理员。它将作为对用户的一种反馈存在。这章中,我们打算向下订单的用户发送确认邮件。在我们实现这个功能后,我们还会对整个用户场景进行测试。

迭代 H1:发送确认邮件

在 Rails 中要发送邮件需要进行三个基础工作,分别是配置如何发送邮件,什么时间发送邮件以及邮件内容。这三个部分待会我们都会一一接触。

邮件配置

邮件配置是 Rails 应用环境的一部分,它处于 Depot::Application.configure 中。如果你希望在开发、测试和生产环境中都使用相同配置的话,就得在 config/environment.rb 文件中添加配置,不过也可以对不同环境进行配置,只要在 config/environments 路径中相应的配置文件编写即可。

在配置文件中你需要添加多个声明。首先是分发邮件的方式。

config.action_mailer.delivery_method = :smtp

:stmp 方式会包含 :sendmail:test

当你希望通过 Action Mailer 进行邮件分发时就可以使用 :smtp:sendmail 选项。不过在生产环境中要清晰表明使用的是哪种方式。

:test 配置有利于单元测试和功能测试,在 183 页测试邮件发送时我们会使用到。通过这个配置在测试环境时邮件将不会被发送,而是添加在数组中(可以通过 ActionMailer::Base.deliveries 访问)。这也是在测试环境时的默认发送方式。不过,在开发环境时默认配置还是 :smtp。如果你希望 Rails 在开发环境时可以向你发送邮件就这样配置,如果不希望在开发环境中发送邮件,可以在 config/environments/development.rb 中添加如下代码:

Depot::Application.configure do
  config.action_mailer.delivery_method = :test
end

:sendmail 设置会委派你本地系统 /usr/sbin 路径下的 sendmail 程序进行邮件发送。这种发送方式并不是太灵活,因为不同操作系统中的 sendmail 程序很可能安装在不同的路径下。而且它还依赖你的本地 sendmail 程序支持 -i-t 命令参数。

你可以通过设置 :smtp 而不使用其默认值获得更大的灵活性。如果你已经进行了这样的配置,你还需要添加一些配置信息,让 Action Mailer 能够知道 SMTP 服务器的地址以便处理你发送的邮件。它也许是你运行 web 应用的机器,也可以是其它外部的机器(如果 Rails 应用是在非企业环境中运行 SMTP 服务器也可以在你的 ISP 中)。你的系统管理员可以提供相应的配置参数。你也可以在自己的邮箱客户端的配置信息中找到它们。

下面是 Gmail 的配置示例,也许这正是你需要的。

# config/environment.rb

Depot::Application.configure do 
  config.action_mailer.delivery_method = :smtp

  config.action_mailer.smtp_settings = {
    address: "smtp.gmail.com",
    port: 587,
    domain: "domain.of.sender.net",
    authentication: "plain",
    user_name: "dave",
    password: "secret",
    enable_starttls_auto: true
  }
end

所有的配置信息修改后都需要重启应用,包括环境配置文件被修改时也需要重启。

发送邮件

现在已经配置完成,是时候编写代码发送邮件了。

使用 Rails 这段时间后,我们对 Rails 会提供相应的生成器脚本创建邮件发送器也不再惊讶。在 Rails 中邮件发送器是存储于 app/mailers 路径下的类。它可以包含多个方法,每个方法都对应相应的一个邮件模板。要创建邮件体时相应的方法会使用 view 进行处理(与 controller action 使用 view 创建 HTML 和 XML 时相似)。让我们为当前的应用开发一个邮件发送器吧。我们要通过它发送两种类型的邮件,一种是下订单时发送,另一种是订单发货后发送。rails generate mailer 命令需要邮件收发机的名称作为参数,这个名称也会作为相应的邮件 action 方法名。

rails generate mailer OrderNotifier received shipped

我们通过上述命令已经在 app/mailers 路径中创建了一个 OrderNotifier 类,还有两个模板文件,都被存储在 app/views/order_notifier 路径下(我们还创建了一个测试文件,在 183 页是会进行使用)。

邮件收发器类中的每个方法都负责建立发送一种指定邮件的环境。在继续处理细节之前让我们看个例子。下面是生成的 OrderNotifier 类的代码,其中有一处默认值进行了修改:

# app/mailers/order_notifier.rb

class OrderNotifier < ApplicationMailer
  # Here's new line
  default from: 'Sam Ruby <depot@example.com>'

  # Subject can be set in your I18n file at config/locales/en.yml
  # with the following lookup:
  #
  #   en.order_notifier.received.subject
  #
  def received
    @greeting = "Hi"

    mail to: "to@example.org"
  end

  # Subject can be set in your I18n file at config/locales/en.yml
  # with the following lookup:
  #
  #   en.order_notifier.shipped.subject
  #
  def shipped
    @greeting = "Hi"

    mail to: "to@example.org"
  end
end

如果你感觉它就像一个 controller 那是因为它们之间的工作方式很相似。每个 action 都有一个方法,不过不是调用 render() 方法而是调用 mail() 方法。邮件可以接收 :to(用于显示)、:cc:from:subject 参数,每个参数的功能如同名字所表达的一样。mailer 中所有邮件共用的参数只需要调用 default 即可进行默认赋值,就像这个类顶部的 :from 一样。这将使你定制自己的邮件时非常方便。

类中的注释也说明了主题行是可以通过转化获得的,在 211 页第 15 章时我们还会讨论主题的相关内容。现在我们只是简单地使用 :subject 参数即可。

就像 controller 一样,模板要包含被发送的文本内容,controller 和 mailer 都可以通过实例变量提供嵌入模板的值。

邮件模板

生成器脚本创建了两个邮件模板,分别对应 Notifier 类中的 action。它们都是常规的 .erb 文件。我们要通过模板创建 plain-text 类型的邮件(后面我们也会了解如何创建 HTML 邮件)。和我们用于创建应用网页的模板一样,这些文件也包含了静态文本和动态内容。我们可以通过编写 received.text.erb 文件定制订单确认邮件:

# app/views/order_notifier.text.erb

Dear <%= @order.name %>

Thank you for your recent order from Pragmatic Store.

You ordered the following items:

<%= render @order.line_items %>

We'll send you a seperate email when your order ships.

我们通过 partial 模板渲染每一项商品的名称和数量。而且我们在这些模板中也依然可以使用常规的辅助方法,比如 truncate() 等方法。

# app/views/line_items/_line_item.text.erb

<%= sprintf("%2d x %s",
            line_item.quantity,
            truncate(line_item.product.title, length: 50)) %>

现在我们需要回到 OrderNotifier 类的 received() 方法中。

# app/mailers/order_notifier.rb

class OrderNotifier < ApplicationMailer
  default from: 'Sam Ruby <depot@example.com>'

  # Subject can be set in your I18n file at config/locales/en.yml
  # with the following lookup:
  #
  #   en.order_notifier.received.subject
  #
  # Here's updated block
  def received(order)
    @order = order

    mail to: order.email, subject: 'Pragmatic Store Order Confirmation'
  end

  #...
end

在此方法中我们要做的就是将 order 添加为方法参数,并且将传入的参数赋值给实例变量,然后指定调用 mail() 时发送邮件的地址以及主题。

生成邮件

我们已经准备了相应的模板和 mailer 方法,已经可以在 controller 中通过它们创建和发送邮件了。

# app/controllers/orders_controller.rb

def create
  @order = Order.new(order_params)
  @order.add_line_items_from_cart(@cart)

  respond_to do |format|
    if @order.save
      Cart.destroy(session[:cart_id])
      session[:cart_id] = nil
      OrderNotifier.received(@order).deliver
      format.html { redirect_to store_url, notice: 'Thank you for your order.' }
      format.json { render :show, status: :created, location: @order }
    else
      format.html { render :new }
      format.json { render json: @order.errors, status: :unprocessable_entity }
    end
  end
end

接着我们要像 received() 方法一样修改 shipped() 方法。

# app/mailers/order_notifier.rb

def shipped(order)
  @order = order

  mail to: order.email, subject: 'Pragmatic Store Order Shipped'
end

此时我们已经拥有了足够的基础向你发送订单情况的邮件通知,尽管在开发环境中你还无法正常发送邮件。稍后我们可以给邮件添加一些格式。

分发多类型邮件

有些人喜欢接收普通文本格式的邮件,不过也有些人更喜欢 HTML 格式的。Rails 让发送不同类型的邮件很方便,这样可以更好地适应客户的需要(或者根据客户的邮件客户端决定)。

前面的章节中我们已经创建了普通文本邮件。received action 的 view 文件被命名为 received.text.erb。这是基于 Rails 标准命名约定。我们还可以创建 HTML 格式的邮件。

现在我们试试处理订单发货提醒邮件。我们不需要修改任何代码,只要添加一个新模板即可。

<!-- app/views/order_notifier/shipped.html.erb -->

<h3>Pragmatic Order Shipped</h3>

<p>
  This is just to let you know that we've shipped your recent order:
</p>

<table>
  <tr>
    <th colspan="2">Qty</th>
    <th>Description</th>
  </tr>
  <%= render @order.line_items %>
</table>

我们甚至可以直接使用之前创建的 partial 模板。

<!-- app/views/line_items/_line_item.html.erb -->

<% if line_item == @current_item %>
  <tr id="current_item">
<% else %>
  <tr>
<% end %>
  <td><%= line_item.quantity %>&times;</td>
  <td><%= line_item.product.title %></td>
  <td class="item_price"><%= number_to_currency(line_item.total_price) %></td>
  <td class="item_remove_button"><%= button_to 'Remove Item', line_item, method: :delete, remote: true, data: { confirm: 'Are you sure?' } %></td>
</tr>

不过对于邮件模板来说有一些命名的技巧。如果通过同一个名字命名了不同类型的模板,Rails 会将它们作为一个邮件发出,并且内容是排列好的以方便邮箱可以区分它们。

所以你需要修改或者删除 Rails 提供的普通文本 shipped 模板。

邮件功能测试

当我们通过生成器创建订单 mailer 时,它会自动在 test/mailers 文件夹中创建相应的 order_notifier_test.rb 文件。其中的测试比较简单,只是调用 action 后验证相应的邮件内容已经产生。既然我们已经定制了邮件,那我们也应该更新相应的测试用例。

# test/maillers/order_notifier_test.rb

require 'test_helper'

class OrderNotifierTest < ActionMailer::TestCase
  setup do
    @order = orders(:one)
    add_item_to_order
  end

  test "received" do
    mail = OrderNotifier.received(@order)
    assert_equal 'Pragmatic Store Order Confirmation', mail.subject
    assert_equal ["dave@example.org"], mail.to
    assert_equal ['depot@example.com'], mail.from
    assert_match /Programming Ruby 1.9/, mail.body.encoded
  end

  test "shipped" do
    mail = OrderNotifier.shipped(@order)
    assert_equal 'Pragmatic Store Order Shipped', mail.subject
    assert_equal ["dave@example.org"], mail.to
    assert_equal ["depot@example.com"], mail.from
    assert_match /<td>1&times;<\/td>\s*<td>Programming Ruby 1.9<\/td>/, mail.body.encoded
  end

  private
    def add_item_to_order
      line_item = LineItem.create(product: products(:ruby))
      line_item.order = @order
      line_item.save!
    end

end

测试方法命令邮件类创建(并没有发送)一封邮件,然后通过断言验证我们期望的动态内容。我们只是通过 assert_match() 验证了邮件体内容的一部分。你的结果会根据你设置的 Notifier 中的 default :from 内容有所不同。

现在我们已经验证了相应的邮件内容和格式都没有问题,不过我们没有验证在客户完成订单流程时它会被发送。所以,接下来我们要使用集成测试。

Joe 提问:我可以接收到邮件吗?

Action Mailer 使我们可以轻松地编写基于 Rails 应用的邮件处理器。不过如果你需要从服务器环境获取邮件并将它们嵌入应用中就需要一些工作了。

最简单的部分就是在应用中处理邮件。在 Action Mailer 类中,编写一个实例方法调用 receive() 方法并接收单个参数。这个参数是与接收的邮件相对应的 Mail::Message 对象。你可以获取字段、邮件体文本以及附件,并且在应用中使用。

所有常用的接收邮件技术都需要运行一个命令,将接收的邮件内容作为标准输入传入命令中。如果我们在一封邮件到达时使用 Rails 的 runner 脚本调用命令,我们便可以将邮件传入应用中相应的处理代码内。例如,使用 procmail-based 拦截时我们可以编写如下方例子的一个规则,这个规则会通过 runner 脚本将主题包含「Bug Report」的邮件进行拷贝:

RUBY=/opt/local/bin/ruby
TICKET_APP_DIR=/Users/dave/Work/depot
HANDLER='IncomingTicketHandler.receive{STDIN.read}'

:0 c
* ^Subject:.*Bug Report.*
| cd $TICKET_APP_DIR && $RUBY bin/rails runner $HANDLER

receive() 类方法对所有的 Action Mailer 都是可用的。它会将邮件文本转换为 Mail 对象,并创建接收类的一个新实例,然后将 Mail 对象传递给这个类的 receive() 实例方法。

迭代 H2:应用集成测试

Rails 组织 model 和 controller 中的测试,也组织集成测试。在对集成测试解释前,我们简要叙述下目前为止我们接触过的测试。

model 的单元测试

model 类包含了业务逻辑。例如当我们向购物车添加商品时,购物车 model 需要检查购物车清单中是否已经存在相应的商品,如果已经存在需要增加其购买数量,如果不存在就要将商品添加至清单中。

controller 的功能测试

controller 直接用于展示。它们可以接收来自网站的请求(比如用户输入),并收集应用状态与 model 交互,接着通过适当的 view 向用户展示响应内容。所以当我们测试 controller 时需要保证发起请求后得到的是适当的响应。虽然功能测试中也会需要 model,不过它们主要通过单元测试覆盖。

测试的下一级别是验证应用中的流程。通常就像是测试在编码前用户提供的故事。

例如我们被告知以下内容:

一个用户进入了商店首页。他选择一件商品并加入购物车。然后他进行结账,在结账时他填写相应的详细信息。当被创建的订单提交后,相应的数据库数据中应当包含刚才填写的信息,以及刚才添加到购物车中的一件商品。当订单被接收时应当向他发出确认订单的邮件。

这就可以作为集成测试的材料。集成测试会模拟用户与应用之间持续的 session。你可以通过 session 发送请求、监控响应、重定向等等。

当你创建了一个 model 或 controller 时,Rails 会创建相应的单元测试或功能测试。不过集成测试并不会被自动创建,你只能通过生成器创建。

rails generate integration_test user_stories

Rails 会添加 _test 作为测试名称。

让我们看看生成的测试文件。

# test/integration/user_stories_test.rb

require 'test_helper'

class UserStoriesTest < ActionDispatch::IntegrationTest
  # test "the truth" do
  #   assert true
  # end
end

接下来我们要根据上面的用户故事实现测试。由于我们要测试购买一件商品,所以我们会用到 products fixture。

但是相比加载所有的 fixture,现在我们只需要加载 products 这一个:

fixtures :products

我们要先创建一个测试,并命名为「buying a product」。在测试结束时,我们期望已经添加一个订单至 orders 表中,并且有一件商品,所以我们开始之前要将其清空。而且由于我们会多次使用 fixture 数据 Ruby book 多次,所以我们现在将它先加载为本地变量。

# test/integration/user_stories_test.rb

LineItem.delete_all
Order.delete_all
ruby_book = products(:ruby)

让我们先来实现用户故事中的第一句:「一个用户进入商店首页」。

# test/integration/user_stories_test.rb

get '/'
assert_response :success
assert_template "index"

尽管看起来像一个功能测试,不过最主要的不同在于 get 方法。在功能测试中,我们关注一个 controller,所以在测试 get() 方法时就是在指 action。而在集成测试中,我们能够调用应用中的一切资源,所以我们需要传递完整(相对)的 URL 才能知道哪个 controller 中的哪个 action 被调用。

故事的下一句是「他选择一件商品并加入购物车」。我们知道应用是通过 Ajax 请求向购物车添加商品的,所以我们要用 xml_http_request() 方法调用 action。当响应返回时我们要检查购物车是否已经包含了相应的商品。

# test/integration/user_stories_test.rb

xml_http_request :post, '/line_items', product_id: ruby_book.id
assert_response :success

cart = Cart.find(session[:cart_id])
assert_equal 1, cart.line_items.size
assert_equal ruby_book, cart.line_items.first.product

接着用户故事讲述的内容是「他进行结账」,在测试中就很简单了。

# test/integration/user_stories_test.rb

get '/orders/new'
assert_response :success
assert_template 'new'

现在用户要在结账的表单中填充相应的信息。当填写完成并将数据提交时,我们的应用创建订单并重定向至首页。让我们通过 HTTP 提交表单数据给 save_order action,并且验证是否重定向至首页。接着我们还要验证购物车已经被清空。post_via_redirect() 辅助方法会生成 post 请求并且携带任意返回的重定向,直到没有重定向响应返回为止。

# test/integration/user_stories_test.rb

get '/orders/new'
assert_response :success
assert_template 'new'

post_via_redirect '/orders',
                  order: {
                    name: 'Hank Xu',
                    address: '123 Main St',
                    email: 'hank@customer.com',
                    pay_type_id: pay_types(:one).id
                  }
assert_response :success
assert_template 'index'

接着,我们要查看数据库并确认已经创建了一个订单,并且包含相应的信息和购买的商品条目。由于我们在测试开始时就已经清空了 orders 表,现在只需要简单验证它是否包含新订单即可。

# test/integration/user_stories_test.rb

orders = Order.all
assert_equal 1, orders.size

order = orders.first

assert_equal 'Hank Xu', order.name
assert_equal '123 Main St', order.address
assert_equal 'hank@customer.com', order.email

assert_equal 1, order.line_items.size
assert_equal ruby_book, order.line_items.first.product

最后,我们要验证邮件的地址是否正确,主题是否为我们期望的。

# test/integration/user_stories_test.rb

mail = ActionMailer::Base.deliveries.last
assert_equal ['hank@customer.com'], mail.to
assert_equal 'Sam Ruby <depot@example.com>', mail[:from].value
assert_equal 'Pragmatic Store Order Confirmation', mail.subject

下面的代码就是完整的集成测试的了:

# test/integration/user_stories_test.rb

require 'test_helper'

class UserStoriesTest < ActionDispatch::IntegrationTest
  fixtures :products
  test "buying a product" do
    LineItem.delete_all
    Order.delete_all
    ruby_book = products(:ruby)

    get '/'
    assert_response :success
    assert_template "index"

    xml_http_request :post, '/line_items', product_id: ruby_book.id
    assert_response :success

    cart = Cart.find(session[:cart_id])
    assert_equal 1, cart.line_items.size
    assert_equal ruby_book, cart.line_items.first.product

    get '/orders/new'
    assert_response :success
    assert_template 'new'

    post_via_redirect '/orders',
                      order: {
                        name: 'Hank Xu',
                        address: '123 Main St',
                        email: 'hank@customer.com',
                        pay_type_id: pay_types(:one).id
                      }
    assert_response :success
    assert_template 'index'

    orders = Order.all
    assert_equal 1, orders.size

    order = orders.first

    assert_equal 'Hank Xu', order.name
    assert_equal '123 Main St', order.address
    assert_equal 'hank@customer.com', order.email

    assert_equal 1, order.line_items.size
    assert_equal ruby_book, order.line_items.first.product

    mail = ActionMailer::Base.deliveries.last
    assert_equal ['hank@customer.com'], mail.to
    assert_equal 'Sam Ruby <depot@example.com>', mail[:from].value
    assert_equal 'Pragmatic Store Order Confirmation', mail.subject
  end
end

总的来说,单元测试、功能测试和集成测试给你提供了可以将每个方面进行独立或组合的测试视角。在 418 页我们还将讲述如何添加下一级别的测试,这种测试可以让你编写关于业务行为的文字描述就能自动验证,并且也易于你的客户进行阅读。

说到客户,是时候将当前迭代结束并看看接下来应用还要实现哪些功能。

总结

通过少量代码和一些模板我们就完成了下列任务:

  • 我们通过配置使测试环境、开发环境和生产环境都可以向外部发送邮件。

  • 我们创建了一个 mailer 并向订购我们商品的用户发送普通文字和 HTML 类型的确认邮件。

  • 我们对生成邮件创建一个功能测试,还创建了一个覆盖整个订单场景的集成测试。

自习天地

下面的知识需要你自己练习:

  • 给 orders 表添加 ship_date 列,当 OrdersController 更新 ship_date 时发送提醒邮件。
  • 当应用出现问题时向系统管理员发送邮件,比如在迭代 E2 中处理异常的地方。
  • 添加一个关于之前已经完成的场景的集成测试。