在Rails 5中接收和解析电子邮件

时间:2024-04-03 19:12:58

与Griddler和Mailgun

在Rails 5中接收和解析电子邮件

SaaS应用程序中电子邮件的另一方面是接收邮件。 尽管与发送相比,这不是很正常,也可以使用,但是它是使最终用户对电子邮件或操作项的响应更快的一种好方法。

在较高的层次上,有几个不同的层次。 最顶层是电子邮件服务,而本书的案例是Mailgun。 该服务处理发送出站电子邮件,以及将传入电子邮件路由到其界面中指定的地址/域名。 电子邮件路由完成后,将被重定向到应用程序中的路由和处理器文件。 该文件将负责解析传入的电子邮件地址,并使用逻辑来决定如何处理它。

注意:本文摘录自我即将出版的《 在Rails 6中构建SaaS应用程序》一书中的一章。 这本书指导您从不起眼的开始到将应用程序部署到生产中。 该书现已开始预售,您现在就可以免费获得一章!

另外,我的新项目Pull Manager的beta已发布。 如果您忘记了拉取请求,或者有旧的请求徘徊,或者只是喜欢一个仪表板,可以将这些请求聚合到多个服务(Github,Gitlab和Bitbucket)上, 请检查一下

对于我们正在构建的Standup App,我们可以指导他们响应电子邮件提醒,从他们的电子邮件响应中创建新的Standup! 我们将使用的一些工具是Mailgun的电子邮件路由服务和HTTP隧道服务ngrok。 在本书的其余部分中, ngrok对于一些解决方案非常有用。 它允许您有一个可通过Web访问的URL,该URL隧道(连接)到本地计算机中的端口。 就是说,您将拥有一个将转发到您的计算机的http://somesubdomain.ngrok.com ,以及在启动ngrok时指定的特定端口。 这使您可以使用Mailgun,Stripe(后来),Github(后来)等外部服务进行测试!

让我们开始一些设置:

  • 下载ngrok
  • 解压缩可执行文件并将其移动到所需位置。
  • 在* nix操作系统中,使用以下命令打开ngrok: path/to/ngrok HTTP start 3000 这将取决于您用于Rails服务器的端口。 现在,ngrok将使用随机生成的URL启动隧道服务。
  • (可选)如果您升级到ngrok的付费版本, ngrok可以设置一个子域,因此不必在每次重新启动ngrok时都在其他位置更改设置。
  • 在Mailgun中,转到“路由”选项卡以创建将发送电子邮件的电子邮件路由(以MVC路由(如Rails)为模型)。 输入以下设置:
  • 选择Match Recipient
  • 在收件人字段中输入development.standup.*@app.yourdomain.com Mailgun路由允许通配符匹配,这将允许您在电子邮件的reply-to输入其他字符。 意思是,添加诸如用户的hash_id类的hash_id以从传入电子邮件中获得可识别的信息。
  • 检查forward并输入http://yoursubdomain.ngrok.io/email_processor作为转发的目的地。
  • 在按下submit按钮之前,您可以保留优先级并为路由命名。

现在完成了,我们可以开始修改应用程序了。 应用程序更改将包括三个主要部分。 首先,将添加一个新的gem(和一个伴随适配器gem)。 griddler是轻松处理传入电子邮件的主要库; griddler-mailgun是Mailgun特定的适配器,它允许griddler功能与Mailgun一起用作传入邮件路由器。 接下来,Griddler将需要在应用程序范围内添加一些配置,以确保满足一些基本配置。 最后,将添加一个电子邮件处理器文件以处理接收和解析电子邮件。

将Griddler添加到当前应用程序的重要之处在于,如果您正在使用第3章中的Gemfile,则已经安装了它。 如果没有,只是添加gem 'griddler'gem 'griddler-mailgun'你的Gemfile并运行bundle install

接下来,要在应用程序中配置和设置Griddler,您将需要在初始化文件夹中添加一个新文件,并设置一些配置值。 然后,添加一条快速行以将默认的Griddler路由添加到routes.rb文件中。

首先是Griddler配置:

Griddler.configure do |config|
config.reply_delimiter = '-- REPLY ABOVE THIS LINE --'
config.email_service = :mailgun
end

这将设置griddler库将在电子邮件中查找的文本,并告知它将使用已安装的Mailgun适配器。

接下来,添加一行以将基于库的路由安装到您的应用程序中。 将行添加到root to:右上方root to:可以:

Rails.application.routes.draw do
...
  # mount using default path: /email_processor
mount_griddler
  root to: 'activity#mine'
end

通过使用该语法安装Griddler,它将自动向您的应用程序添加一条路由,该路由将从指定的端点路由到基于Griddler的控制器:

email_processor POST /email_processor(.:format)      
griddler/emails#create

Griddler的GitHub文档中有一些设置可以更改默认值,但是除非您想通过路由路径或电子邮件处理器类名来发挥创意,否则就没有必要了。

默认设置要求类EmailProcessor存在,并使用方法process处理解析传入的电子邮件。 但是,Griddler并不关心实际文件的位置,但是该类存在并已加载。 我个人认为,电子邮件处理最适合服务定义,并且可以放在其中。

为了使Griddler及其文件能够从传入的答复中捕获文本,需要进行一些更改。

首先,我们将更新EmailReminderMailer以创建唯一的回复地址,并将该电子邮件地址作为外发电子邮件的一部分包括在内:

class EmailReminderMailer < ApplicationMailer
def reminder_email(user, team)
@user = user
@team = team
reply_to = "'Standup App' <#{'development.' if Rails.env.development?}standup.#{ @app .yourdomain.com">user.hash_id} @app .yourdomain.com>"
mail(
to: @user .email,
subject: "#{team.name} Standup Reminder!",
reply_to: reply_to
)
end
end

如果当前的Rails环境是您的本地Rails应用程序,则在这里我们通过添加development来构建reply_to字符串。 这样,用于开发的单独Mailgun路由可以与您将在部署应用程序之前添加的生产路由不同。

接下来,我们将更新邮件模板,使它具有##- Please type your reply above this line -##并输入一些文本,使电子邮件收件人知道他们可以通过以下方式添加站立文字:

## app/views/email_reminder_mailer/reminder_email.html.slim
doctype html
html xmlns=" http://www.w3.org/1999/xhtml "
head
meta content="width=device-width" name="viewport" /
meta content=("text/html; charset=UTF-8") http-equiv="Content-Type" /
title= "#{ @team .name} Reminder!"
css:
| *{margin:0;padding:0;font-family:"Open Sans",Helvetica,Helvetica,Arial,
sans-serif;box-sizing:border-box;font-size:14px}img{max-width:100%}body{-
webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;width:100
%!important;height:100%;line-height:1.6}table td{vertical-align:top}body{
background-color:#f6f6f6}.body-wrap{background-color:#f6f6f6;width:100%}.
container{display:block!important;max-width:800px!important;margin:0
auto!important;clear:both!important}.content{max-width:800px;margin:0
auto;display:block;padding:20px}.main{background:#fff;border:1px solid #e
9e9e9;border-radius:3px}.content-wrap{padding:20px}.content-block{padding
:0 0 20px}.header{width:100%;margin-bottom:20px}.footer{width:100%;clear:
both;color:#999;padding:20px}.footer a{color:#999}.footer a,.footer
p,.footer td,.footer unsubscribe{font-size:12px}h1,h2,h3,a,th,td{font-
family:"Open Sans",Helvetica,Arial,"Lucida Grande",sans-serif;color:#
000;margin:40px 0 0;line-height:1.2;font-weight:400}h1{font-size:32px;
font-weight:500}h2{font-size:24px}h3{font-size:18px}h4{font-size:14px;
font-weight:600}ol,p,ul{margin-bottom:10px;font-weight:400}ol li,p li,ul
li{margin-left:5px;list-style-position:inside}a{color:##3c8dbc;text-decor
ation:underline}.btn-primary{text-decoration:none;color:#
FFF;background-color:##3c8dbc;border:solid ##3c8dbc;border-width:5px 10px
;line-height:2;font-weight:700;text-align:center;cursor:pointer;display:
inline-block;border-radius:5px;text-transform:capitalize}.last{margin-
bottom:0}.first{margin-top:0}.aligncenter{text-align:center}.alignright{
text-align:right}.alignleft{text-align:left}.clear{clear:both}.alert{font
-size:16px;color:#
fff;font-weight:500;padding:20px;text-align:center;border-radius:3px 3px
0 0}.alert a{color:#fff;text-decoration:none;font-weight:500;font-size:16
px}.alert.alert-warning{background:#f8ac59}.alert.alert-bad{background:#e
d5565}.alert.alert-good{background:##3c8dbc}.invoice{margin:40px
auto;text-align:left;width:80%}.invoice td{padding:5px 0}.invoice .
invoice-items{width:100%}.invoice .invoice-items td{border-top:#eee 1px
solid}.invoice .invoice-items .total td{border-top:2px solid #
333;border-bottom:2px solid #333;font-weight:700} @media only screen and (
max-width:640px){h1,h2,h3,h4{font-weight:600!important;margin:20px 0 5px!
important}h1{font-size:22px!important}h2{font-size:18px!important}h3{font
-size:16px!important}.container{width:100% !important}
.content,.content-wrap{padding:10px !important}.invoice{width:10
0% !important}
body
div style="color: #b5b5b5;text-align:center;"
| ##- Please type your reply above this line -##
table.body-wrap style="width:100%"
tr
td
td.container width="800"
.content
table.main cellpadding="0" cellspacing="0" width="100%"
tr
td.content-wrap
table cellpadding="0" cellspacing="0" style="width:100%"
tr
td.aligncenter
| Standup App
tr
td.content-block
h3= "#{ @team .name} Reminder!"
tr
td.content-block
= "Just wanted to remind you to add your standup for \
the team: #{ @team .name}"
tr
td.content-block.aligncenter
= link_to "Add Your Standup", new_standup_url(), \
{class:"btn-primary", style: "width:95%"}
tr
td.content-block
= "You can quickly submit your standup by replying to \
this email in the format:"
pre
pre
= "[d] This is a done item\n[t] This is a todo item\n\
[b] This is a blocker"
.footer
table width="100%"
tr
td.aligncenter.content-block
td

最后,我们需要在Standups表中添加一个额外的列,以跟踪来自Mailgun路由电子邮件的Message-ID 由于您不能依靠电子邮件服务来提供“仅一次”传递,因此我们需要在Standup表上自己跟踪这些唯一的ID。

rails g migration AddMessageIdToStandups message_id

接下来,在新创建的迁移change方法结束之前,您将要添加add_index :standups, :message_id 随着Standups表的增长,该索引将允许快速查找。 最后,迁移实际更改:

bin/rails db:migrate

有了这些更改,我们现在可以添加新的EmailProcessor类来解析传入的电子邮件:

class EmailProcessor
attr_reader :email
def initialize(email)
@email = email
end
TASK_TYPE_HASH = {
'[d]' => 'Did',
'[t]' => 'Todo',
'[b]' => 'Blocker'
}
def process
if Rails.env.development?
Rails.logger.info '-----------EMAIL-------------'
Rails.logger.info email.to.first[:token]
Rails.logger.info email.body
Rails.logger.info email.headers["Message-ID"]
Rails.logger.info '-----------EMAIL-------------'
end
# Get a user hash_id from reploy-to or bail
reply_user = email.to.first[:token]&.split('<')&.last&.split('@')&.first&.
split('.')&.last
return if reply_user.blank?
# Find a user by the hash_id or bail
user = User.find_by(hash_id: reply_user)
return if user.nil?
# Bail if standup with incoming message-id exists
return if Standup.exists?(message_id: email.headers["Message-ID"])
# Bail if a standup for today exists
today = Date.today.iso8601
return if Standup.exists?(standup_date: today)
# Get content or bail
tasks_from_body = email.body.scan(/(\[[dtb]{1}\].*)$/)
return if tasks_from_body.blank? || tasks_from_body.empty?
build_and_create_standup(
user: user,
tasks: tasks_from_body,
date: today,
message_id: email.headers["Message-ID"]
)
end
private
def build_and_create_standup(user:, tasks:, date:, message_id:)
standup = Standup.new(
user_id: user.id,
standup_date: date,
message_id: message_id
)
tasks.each do |task|
task_type, task_body = task.first.scan(/(\[[dtb]\])(.*)$/).flatten
standup.tasks << Task.new(type: TASK_TYPE_HASH[task_type], title: task_body)
end
standup.save
end
end

该类相对简单,但让我们逐节介绍一下:

class EmailProcessor
attr_reader :email
def initialize(email)
@email = email
end
TASK_TYPE_HASH = {
'[d]' => 'Did',
'[t]' => 'Todo',
'[b]' => 'Blocker'
}
...

在这里,当Griddler调用该类时,我们将初始化对象,将电子邮件设置为本地电子邮件变量。 此外,我们正在创建一个哈希,以供以后在文本内容到Task类型的转换中使用。

...
def process
if Rails.env.development?
Rails.logger.info '-----------EMAIL-------------'
Rails.logger.info email.to.first[:token]
Rails.logger.info email.body
Rails.logger.info email.headers["Message-ID"]
Rails.logger.info '-----------EMAIL-------------'
end
...

如果邮件是在本地开发环境中处理的,这只会添加一些额外的日志记录。

...
# Get a user hash_id from reply-to or bail
reply_user = email.to.first[:token]&.split('<')&.last&.split('@')&.first&.
split('.')&.last
return if reply_user.blank?
# Find a user by the hash_id or bail
user = User.find_by(hash_id: reply_user)
return if user.nil?
# Bail if standup with incoming message-id exists
return if Standup.exists?(message_id: email.headers["Message-ID"])
# Bail if a standup for today exists
today = Date.today.iso8601
return if Standup.exists?(user_id: user.id, standup_date: today)
# Get content or bail
safe_body = Rails::Html::WhiteListSanitizer.new.sanitize(email.body)
tasks_from_body = safe_body.scan(/(\[[dtb]{1}\].*)$/)
return if tasks_from_body.blank? || tasks_from_body.empty?
...

在这里,我们将获取解析中使用的一些信息,并为process方法提供机会,如果传入的电子邮件不足以进行处理和创建Standup则可以提早退出。 在第一部分中,解析传入的电子邮件地址以查找用户的hash_id 然后,该字符串用于查找用户。 如果没有用户,该方法将返回而不添加Standup。

如果已经有使用当前Message-ID的站立,则下一行将尽早退出该方法。 再次,这确保了防止电子邮件提供商不保证“仅一次”交付。 接下来,为当前日期生成一个变量,并确保当前用户和当前时间没有任何争议。

最后,使用正则表达式解析实际的电子邮件内容。 正则表达式是一种编程语言,它使您可以对字符串进行模式匹配,甚至捕获模式匹配的部分。 这个特定的模式(您可以在此处获得更详细的语法说明)将搜索以[d][t][r]开头的行。 如果存在这些内容,它将捕获内容到行尾。 内容主体上的.scan方法允许其捕获上述模式的所有出现。 如果扫描的输出为空,则退出process方法。

build_and_create_standup(
user: user,
tasks: tasks_from_body,
date: today,
message_id: email.headers["Message-ID"]
)
end
private
def build_and_create_standup(user:, tasks:, date:, message_id:)
standup = Standup.new(
user_id: user.id,
standup_date: date,
message_id: message_id
)
tasks.each do |task|
task_type, task_body = task.first.scan(/(\[[dtb]\])(.*)$/).flatten
standup.tasks << Task.new(type: TASK_TYPE_HASH[task_type], title: task_body)
end
standup.save
end
end

此处的最后一部分是到目前为止存储的所有信息的总结,这些信息将被保存到新的Standup usertoday tasks_from_bodymessage_id都传递到将进行实际保存的方法中。 build_and_create_standup方法使用用户的ID,日期和message_id创建一个新的Standup 创建对象后,将遍历任务字符串以构建具有类型的Task ,并使用<<语法将其分配为子对象。 最后,带有子Tasks的新Standup对象将与standup.save保存在一起standup.save

最后,如果您回复包含以下文本的电子邮件(通过Mailgun SMTP发送,而不是letter_opener),则可以测试所有功能:

[d] Did a thing
[d] And Another
[t] Something to do
[b] Something in the way. Some really long line about something or another

测试这需要有相当多的一种新的规范文件, it的块来测试的所有分支EmailProcessor可能会遇到的问题。

首先,最好是创建一个能够快速生成要在EmailProcessor规范中使用的电子邮件的EmailProcessor 通过这种方式,电子邮件可以有默认值,然后我们可以使用FactoryGirl .build命令需要测试的处理器时,创建一个新的电子邮件对象有任何不同的属性。

工厂本身非常简单:

factory :email, class: OpenStruct do
# Assumes Griddler.configure.to is :hash (default)
to [
{
full: ' [email protected] ',
email: ' [email protected] ',
token: 'to_user',
host: 'email.com',
name: nil
}
]
from(
token: 'from_user',
host: 'email.com',
email: ' [email protected] ',
full: 'From User < [email protected] >',
name: 'From User'
)
subject 'email subject'
body '[d] Did a thing\n[t] Doing a thing\n[b] Blocked by a thing'
headers {'Message-ID <[email protected]>'}
end
end

现在,有了可用的工厂, email_processor_spec将能够根据需要通过特定的更改轻松启动新的电子邮件对象,以测试所有处理器的条件分支。

require 'rails_helper'
describe EmailProcessor do
subject(:email_processor) { EmailProcessor }
let(:user) { FactoryGirl.create(:user) }
let(:email) do
FactoryGirl.build(:email,
to: [
{
email: " @app .buildasaasappinrails.com">standup.#{user.hash_id} @app .buildasaasappinrails.com",
token: " @app .buildasaasappinrails.com">standup.#{user.hash_id} @app .buildasaasappinrails.com"
}
]
)
end
describe 'processes incoming email' do
it 'works as intended' do
expect { email_processor.new(email).process }
.to change(Standup, :count).by(1)
end
it 'fails on bad to' do
bad_to = FactoryGirl.build(
:email,
to: [{ token: nil, email: ' [email protected] ' }]
)
expect { email_processor.new(bad_to).process }
.to change(Standup, :count).by(0)
end
it 'fails on no user' do
bad_to = FactoryGirl.build(
:email,
to: [
{
token: ' [email protected] ',
email: ' [email protected] '
}
]
)
expect { email_processor.new(bad_to).process }
.to change(Standup, :count).by(0)
end
it 'only saves one per message-id' do
expect do
email_processor.new(email).process
email_processor.new(email).process
end.to change(Standup, :count).by(1)
end
it 'only saves one per date' do
email2 = FactoryGirl.build(:email, headers: { 'message-id': '123' })
expect do
email_processor.new(email).process
email_processor.new(email2).process
end.to change(Standup, :count).by(1)
end
it 'fails on empty or bad body' do
email = FactoryGirl.build(:email, body: '90ioqwhdk.qhdu')
email2 = FactoryGirl.build(:email, body: '')
expect do
email_processor.new(email).process
email_processor.new(email2).process
end.to change(Standup, :count).by(0)
end
end
end

虽然很长,包含六个示例,但此规范实际上非常简单。 它首先测试了一切就绪并正常工作的幸福道路。 然后测试不创建一个站立,为了那些路径出现在每个失败路径EmailProcessor.process方法。

快速浏览整个rspec套件应显示没有失败的测试,并且测试/代码覆盖范围几乎完美:

rspec spec ........................................................................................................................................................
Finished in 25.78 seconds (files took 10.16 seconds to load) 152 examples, 0 failures Coverage report generated for RSpec to standup_app/coverage. 428 / 431 LOC (99.3%) covered.

From: https://hackernoon.com/receiving-and-parsing-email-in-rails-5-c975c2766364