分享 用 Mailgun 来做的讨论版 Email 辅助流程 (很多代码)

knwang · January 18, 2013 · Last by knwang replied at January 18, 2013 · 8901 hits

我给 Mailgun 写了一篇博客,原文在这里 - http://blog.mailgun.net/post/40719408774/ruby-tutorial-how-tealeaf-academy-increased-student


At Tealeaf Academy, creating a “Study Together, Progress Together” experience for our students is at core of our way of teaching. One of our core tools is the discussion board where students ask questions, share ideas, collaborate on homework assignments, and teachers quickly jump in to help students get unstuck on problems. One of our recent priorities was to reduce friction in discussion board usage and encourage more discussions with a complementary email notification and a “reply-to email to post on discussion board” workflow. Once we implemented the below code using the Mailgun Routes API, activity on our discussion board increased three fold, and questions are now typically getting answered within an hour, sometimes even minutes, and students are able to move on the next set of tasks a lot quicker. Here’s how we did it:

Our workflow would go as the following:

  1. When a post (most likely, a question) is created, all course participants are notified by email notification
  2. Course participants can reply to the email notifications directly from their email inbox, without having to sign into the course
  3. That reply will be posted on the online discussion board, and are also sent to other course participants, to keep the conversation going.

Why we choose Mailgun

The key piece of this workflow is to receive and parse inbound email messages. We looked around for several email service providers, and in the end picked Mailgun because:

  • It is very developer friendly - We are developers, and Mailgun speaks to us. The APIs expose a lot of low level options that allows tweaking. We like the Routes in particular -It’s a nice layer of abstraction that makes integration with apps very easy. (see how we use it below)
  • It is the most feature complete service we have found - we can use Mailgun for transactional emails, campaigns as well as email lists - it’s nice to have just one service provider to handle everything we need.
  • The price is reasonable and the upgrading path to dedicated IP and custom DKIM is nice, even we do not need it yet.
  • The support is top notch. There is a live chat that I can talk to their developers directly on issues and it has been very useful for us to get issues resolved.

Setting up the email infrastructure

  • The first step was to create a new domain on Mailgun. In our case, it’s messaging.gotealeaf.com which we use for sending and receiving emails.

  • Next, we created a MailgunGateway as our wrapper for Mailgun’s API. This wrapper takes care of all our interactions with Mailgun through our account. Here we use Mailgun’s send_batch_messages API to send HTML emails. It allows us to send a message to multiple receivers with a single API call. We keep our API key as environment variables on the server for extra security. There is also a simple delivery filter such that no emails are sent in the development environment; On staging, all emails are sent to Chris (my co-founder) and myself, so we can test out things without fearing to spam our users; Only on the production environment emails are sent to real users.

class MailgunGateway
  def send_batch_message(options={})
    RestClient.post(messaging_api_end_point,
        from: default_sender,
        to: delivery_filter(options[:to]),
        subject: options[:subject],
        html: options[:body],
        :"h:Reply-To" => options[:reply_to],
        :"recipient-variables" => options[:recipient_variables]
        ) if Rails.env.staging? || Rails.env.production?
    end
  end

  private

  def default_sender
    "Tealeaf Academy "
  end

  def api_key
    @api_key ||= ENV['mailgun_api_key']
  end

  def messaging_api_end_point
    @messaging_api_end_piont ||= "https://api:#{api_key}@api.mailgun.net/v2/messaging.gotealeaf.com/messages"
  end

  def delivery_filter(emails)
    Rails.env.production? ? emails : "[email protected], [email protected]"
  end
end

Add tracking token for posts to identify the “thread” that comments should be collated to

Every post carries a tracking token, which will be included in the “Reply-To” header in the email to collate inbound email replies to the corresponding thread. This method makes it very easy to post the correct reply to the correct thread.

class Post < ActiveRecord::Base
  include Tokenable

  belongs_to :user
  belongs_to :course
  has_many :comments, order: "created_at ASC", dependent: :destroy

  ...

end

module Tokenable
  extend ActiveSupport::Concern

  included do
    after_create do
      self.token = Digest::MD5.hexdigest(Time.now.to_s.split(//).sort_by {rand}.join).first(8)
      self.save
    end
  end
end

class Comment < ActiveRecord::Base

  belongs_to :user
  belongs_to :post, touch: true

  delegate :token, to: :post

  def commenter_name
    user.name
  end

end

Send email notifications for new post or comment

Once a post or comment is created, we send an email notification to all course participants. We are sending emails synchronously for now, but as we have more users, we’ll probably want to offload this to a background job.

class Courses::PostsController < AuthenticatedController
  expose(:course)
  expose(:posts) { course.posts }
  expose(:post)

  def create
    post.user = current_user
    post.save
    CourseNotifier.new(course).notify_course_participants_on_new_discussion(post)
    redirect_to course_home_path(course)
  end

  ...
end

class Courses::Posts::CommentsController < AuthenticatedController
  expose(:course)
  expose(:posts) { course.posts }
  expose(:post)
  expose(:comments) { post.comments }
  expose(:comment)

  def create
    comment.user = current_user
    comment.save
    CourseNotifier.new(course).notify_course_participants_on_new_discussion(comment)
    redirect_to course_home_path(course)
  end

  ...
end

The CourseNotifier is the class where we put our application specific logic on notifications. Note that MailgunGateway is injected in as the default gateway - this is from when we used to have multiple email service providers for campaigning, lists and transactional emails. It is less of a need now that we consolidated all email delivery needs to Mailgun!

class CourseNotifier

  attr_reader :course, :gateway

  def initialize(course, gateway=MailgunGateway.new)
    @course = course
    @gateway = gateway
  end

  def notify_course_participants_on_new_discussion(discussion)
    gateway.send_batch_message(
      to: notification_recipients(discussion).map(&:email).join(", "),
      subject: notification_subject(discussion),
      body: discussion_notification_text(discussion),
      reply_to: reply_to_address(discussion),
      recipient_variables: recipient_variables(
        notification_recipients(discussion)
      )
    )
  end

  ...

  private

  def notification_recipients(discussion)
    course.participants.reject {|participant| participant.email == discussion.user.email }
  end

  def notification_subject(discussion)
    discussion.is_a?(Post) ?
    "[Tealeaf Academy] #{discussion.user.name} Posted a New Message on the Discussion Board" :
    "[Tealeaf Academy] #{discussion.user.name} Replied to a Message on the Discussion Board"
  end

  def reply_to_address(discussion)
    "reply+#{discussion.token}@messaging.gotealeaf.com"
  end

  def recipient_variables(recipients)
    vars = recipients.map do |recipient|
      "\"#{recipient.email}\": {\"name\":\"#{recipient.name}\"}"
    end
    "{#{vars.join(', ')}}"
  end

  def discussion_notification_text(discussion)
<<-EMAIL
<HTML><body>
Hi %recipient.name%,

<p>#{discussion.user.name} says on the course dicussion board:</p>

"#{discussion.text}"
<br/>
<p>Reply to this email directly or <a href="http://www.gotealeaf.com/courses/#{course.slug}/home">view it on the discussion board</a></p>
</body></html>
EMAIL
  end
end

The reply_to_address is where we insert the post token into the “Reply-To” header. The content of the emails are quite simple so we just put them here in the class. If we had a more elaborate email style, we would have used template rendering to handle it. With Mailgun’s send_batch_message API, we can call the API just once to send to multiple recipients, and the recipient_variables method is where we customize email messages for each receiver to include their names to add a personal touch.

Handling inbounding messages in the application

Heading over to Mailgun, under “Routes” in the Control Panel, we created a route as the following:

Filter Expression: match_recipient(“reply+(.*)@messaging.gotealeaf.com”) Action: forward(“http://www.gotealeaf.com/api/incoming_messages/?post_token=1”\) When a user replies to an email, they reply it to an email address such as “[email protected]”, and this route will forward the email to a web hook that we expose to handle incoming messages.

class Api::IncomingMessagesController < ApplicationController
  skip_before_filter :verify_authenticity_token

  def create
    user = User.where(email: params['sender']).first
    post = Post.where(token: params['post_token']).first
    text = params["stripped-text"]
    if post && user && text.present?
      comment = post.comments.create(user: user, text: text)
      CourseNotifier.new(post.course).notify_course_participants_on_new_discussion(comment)
    end
    head(200)
  end
end

Here, we use the sender’s email to find the author, and use the post token to find the post that this reply should be collated under. Mailgun gives us the very useful stripped text which strips away the original message part to only contain the actual reply! In the end, we return a 200 header to tell Mailgun that this interaction is successful, otherwise Mailgun will think our server is down and will faithfully keep trying to call our webhook.

The result from implementing this workflow is impressive - activity on our discussion board increased three fold, and questions are now typically getting answered within an hour, sometimes even minutes, students are able to move on the next set of tasks a lot quicker and we are very happy how this turned out.

选择的很好,下一步可以使用 Pusher 来实现实时效果的互动。

#1 楼 @xds2000 有计划做,但不着急。 :) 主要精力还在市场上。

英文的。。

Mailgun 的 Routes 可以通过 API 动态添加么?

#5 楼 @knwang 哇!好东西!

#6 楼 @hooopo 是啊,很强大。支持 Regex, 可以 forward 到 URL 或者 email address. 可以用来做很多

You need to Sign in before reply, if you don't have an account, please Sign up first.