← Blog

Track Opened Emails In Rails

Tracking opened emails is a technique that, if used responsibly, can provide utility to both you and your users.

Here are a few use cases where it makes sense to me:

  • Appointment updates. If your appointment in a car dealership was rescheduled, an email is sent. If the email is not read in the next couple of hours, a repeat email can be sent, or a CS agent may follow up with a call.
  • Uber-style fulfillment, where contractors are notified of lucrative jobs available to them. If nobody claims a job, a CS agent can go through users who are known not to have read the email.
  • Providing event organizers with estimates of how many people are going to show up based on the number of opened emails.

In this article, we are going to implement such a system for transactional emails using Rails and Postmark (a service that sends emails). We are going to use the Postmark gem.

Usage

First of all, let’s see how the code usage of the tool we are building is going to look like:

def new_blog_post(blog_post, subscriber)
    store_message(
      email_name: 'new_blog_post',
      entity: blog_post,
      user: subscriber
    )

    mail(
      to: subscriber.email,
      subject: "New Post: #{blog_post.title}",
      track_opens: 'true'
    )
end
all_emails = blog_post.sent_emails.count
opened_emails = blog_post.sent_emails.opened.count
open_rate = opened_emails.fdiv(all_emails)

What is a tracking pixel?

By adding track_opens: ‘true’ you are instructing Postmark to include a unique 1x1 image in the email. When a user opens that email, the image gets loaded, which notifies Postmark that the email was opened. It may not always work, depending on the email client and the user’s settings.

Implementation

Setup

To track email status we are going to create records in a database table:

class CreateSentEmails < ActiveRecord::Migration[6.1]
  def change
    create_table :sent_emails do |t|
      t.text :email_name, null: false
      t.text :message_id
      t.references :entity, polymorphic: true, index: true
      t.references :user, foreign_key: true, null: true, index: true
      t.integer :status, default: 0, null: false
      t.datetime :opened_at
      t.text :error
      t.timestamps

      t.index :email_name
      t.index :entity_id
      t.index :message_id
    end
  end
end
class SentEmail < ApplicationRecord
  belongs_to :entity, polymorphic: true
  belongs_to :user, optional: true
  enum status: { sent: 0, opened: 1, failed: 2 }
  validates_presence_of :email_name, :status
  validates_presence_of :message_id, unless: :failed?
end

The next step is to define a callback that’s going to be executed any time an email is sent, for that we need to add the following code to initializers:

# place this file in config/initializers

class PostmarkMailObserver
  def self.delivered_email(m)
    # only create a record if API has accepted the message
    return unless m.delivered?

    # as a part of API we are going to assume that
    # an email should be saved if "email_name" is set
    return unless m.metadata['email_name'].present?

    SentEmail.create(
      email_name: m.metadata['email_name'],
      status: 'sent',
      message_id: m.message_id,
      entity_id: m.metadata['entity_id'],
      entity_type: m.metadata['entity_type'],
      user_id: m.metadata['user_id'],
      subject: m.subject
    )
  end
end

ActionMailer::Base.register_observer(PostmarkMailObserver)

Mailer

To pass these params from our mailer we need to define the store_message method.

With this code any time we successfully send an email we are also going to create a record in the sent_emails table.

class ApplicationMailer < ActionMailer::Base
  def new_blog_post(blog_post, subscriber)
    # by calling "store_message" we are saying that this
    # emails need to be saved in our database
    # for further tracking
    store_message(
      email_name: 'new_blog_post',
      entity: blog_post,
      user: subscriber
    )

    mail(
      to: subscriber.email,
      subject: "New Post: #{blog_post.title}",
      # this param is required if you want Postmark to add a tracking pixel
      # and send you status updates
      track_opens: 'true'
    )
  end

  protected

  # email_name - some name we can later use for statistics
  # entity - any ActiveRecord model we want to associate the email with
  # user - user this email is sent to
  def store_message(email_name:, entity:, user: nil)
    self.metadata['email_name'] = email_name.to_s.truncate(80)
    self.metadata['entity_id'] = entity.id
    self.metadata['entity_type'] = entity.class.name
    self.metadata['user_id'] = user.id if user
  end
end

Webhooks

The next step is to start accepting webhooks from Postmark.

Let’s define a webhook for opening an email, then do a similar thing for bounced emails.

Postmark webhook configuration screen

And controller code that’s going to handle these webhooks:

Rails.application.routes.draw do
  post 'postmark_opened', to: 'postmark#email_opened'
  post 'postmark_bounced', to: 'postmark#email_bounced'
end
class PostmarkController < ActionController::Base
  skip_before_action :verify_authenticity_token
  # we are going to secure this webhook endpoint by using basic auth,
  # when defining your webhook on Postmark you should set it as
  # https://<username>:<password>@example.com/postmark_opened
  # https://<username>:<password>@example.com/postmark_bounced
  # TODO: use real credentials for basic auth
  http_basic_authenticate_with name: "SECRET_NAME", password: "SECRET_PASSWORD"

  def email_opened
    EmailOpenedService.call(
      message_id: params[:MessageID],
      first_open: params[:FirstOpen],
      opened_at: params[:ReceivedAt]
    )
    render json: { status: 201 }
  end

  def email_bounced
    EmailBouncedService.call(
      message_id: params[:MessageID],
      error_message: params[:Description]
    )
    render json: { status: 201 }
  end
end

Here’s how these services are implemented:

class EmailOpenedService
  def self.call(message_id:, first_open:, opened_at:)
    return unless first_open

    sent_email = SentEmail.find_by_message_id(message_id)
    return unless sent_email

    sent_email.update!(error: nil, status: 'opened', opened_at: opened_at)
  end
end
class EmailBouncedService
  def self.call(message_id:, error_message:)
    sent_email = SentEmail.find_by_message_id(message_id)

    return unless sent_email

    sent_email.update!(error: error_message, status: 'failed')
  end
end

That is all, use responsibly, and thank you for reading!

p.s. you can see the full example repo here.