@madpilot rants

Track email opens using a pixel tracker from Rails

If you are sending out emails to you customers from your web app, it pretty handy to know if they are opening them. If you have ever sent out an email newsletter from a service like Campaign Monitor, you would have seen email open graphs. Of course, tracking this stuff is super important for a newsletter campaign, but it would also be interesting to see if users are opening, for example, welcome emails or onboarding emails.

The simplest way to do this is via a tracking pixel – a small, invisible image that is loaded off your server every time the email is opened (See caveat below). This is fairly simple to achieve using Rails by building a simple Rack application.

Caveat

This only works for HTML emails (unless you can work out how to embed an image in a plain text email), and relies on the user having “Load images” turned on. Clearly, this isn’t super accurate, but it should give you a decent estimate.

The Setup

We’ll add two models: One to tracking sending email, and one to track opening of email:

rails generate model sent_email
rails generate model sent_email_open

The schema for these are fairly simple: Save a name to identify the email, the email address it was sent to, an ip address and when the email was sent and opened.

class CreateSentEmails < ActiveRecord::Migration
  def change
    create_table :sent_emails do |t|
      t.string :name
      t.string :email
      t.datetime :sent
      t.timestamps
    end
  end
end
class CreateSentEmailOpens < ActiveRecord::Migration
  def change
    create_table :sent_email_opens do |t|
      t.string :name
      t.string :email
      t.string :ip_address
      t.string :opened
      t.timestamps
    end
  end
end
class SentEmail < ActiveRecord::Base
  attr_accessible :name, :email, :sent
end
class SentEmailOpen < ActiveRecord::Base
  attr_accessible :name, :email, :ip_address, :opened
end

With the models setup, let’s add a mail helper that will generate the image pixel – create this is /app/helpers/mailer_helper.rb

module MailerHelper
  def track(name, email)
    SentEmail.create!(:name => name, :email => email, :sent => DateTime.now)
    url = "#{root_path(:only_path => false)}email/track/#{Base64.encode64("name=#{name}&email=#{email}")}.png"
    raw("<img src=\"#{url}\" alt="" width=\"1\" height=\"1\">")
  end
end

What this does is give our mailers a method called track that takes a name for the email and the email address of the person we are sending it to. To enable it, call the helper method in the mailers you want to track:

class UserMailer < ActionMailer::Base
  helper :mailer
end

Now we can add the tracker to our html emails. Say we have a registration email that goes out, and there is a user variable, with an email attribute:

<!-- Snip -->
<%= track('register', @user.email) %>
<!-- Snip -->

Right, now the magic bit:

Create a directory called /lib/email_tracker and create a new file called rack.rb

module EmailTracker
  class Rack
    def initialize(app)
      @app = app
    end

    def call(env)
      req = ::Rack::Request.new(env)

      if req.path_info =~ /^\/email\/track\/(.+).png/
        details = Base64.decode64(Regexp.last_match[1])
        name = nil
        email = nil

        details.split('&').each do |kv|
          (key, value) = kv.split('=')
          case(key)
          when('name')
            name = value
          when('email')
            email = value
          end
        end

        if name && email
          SentEmailOpen.create!({
            :name => name,
            :email => email,
            :ip_address => req.ip,
            :opened => DateTime.now
          })
        end

        [ 200, { 'Content-Type' => 'image/png' }, [ File.read(File.join(File.dirname(__FILE__), 'track.png')) ] ]
      else
        @app.call(env)
      end
    end
  end
end

Create a 1×1 pixel transparent PNG and save it as track.png and place it in the same directory. Next, include it in your config/application.rb

module App
    class Application < Rails::Application
        require Rails.root.join('lib', 'email_tracker', 'rack')
        # Some other stuff
        config.middleware.use EmailTracker::Rack
    end
end

And that’s it! Now, every time the email gets sent out, it will create a record in the send_emails table, and if it is opened (and images are turned on) it will create a record in send_email_opens. Doing up a status board is left up as an exercise to the user, but you can check you percentage open rate by doing something like:

(SentEmailOpen.where(:name => 'register').count.to_f / SentEmail.where(:name => 'register').to_f) * 100

How it works

It’s super simple. The track method generates a Base64 encoded string that stores the name of the email and the email address it is being sent to. It them returns an image URL that can be embedded in the email. The Rack app looks for a URL that looks like /track/email/[encodedstring].png and if it matches records the hit. It then returns a transparent PNG to keep the email client happy.

I might get around to turning this into a gem if there is enough interest.

12 comments

  1. Interesting solution. But how does this work without specifying a route for the image path?

    I am getting the following error:
    ActionController::RoutingError (No route matches [GET] "/email/track/ZW1haWw9VC9VeVRiUFFFRUE0VmNrcWRmZE1YNUhWdmgveWRGNW1tcVJpR1Iy++++++++++++VHE1TT0mbWVzc2FnZT1Qd05OVVVkS2ExQkxGbWpOUWd6SGU2SUpVN3hpNGZL++++++++++++c0JRZ0NmNHBUaVpRPQ==++++++++++++.png")
  2. Hey Lukas,

    It sounds like the middleware isn't being loaded. There is no need for routes, as this code acts as a Rack app and should capture the URL before it hits the Rails routing logic.

    Do you see "EmailTracker::Rack" if you run rake routes?
  3. I dont understand why you are using 2 models for this. A single model would have done the job. Just create it when you call track and update it afterward.
    1. Because users can open the email more than once, which I also wanted to track.

      Though, what I should have done is added a foreign key to the open table, so it could be tracked a little better (it gets confused if two emails with the same id get sent out to the same person)
  4. Very interesting base-block approach and post. Still not production ready though (many edge cases and bugs to be fixed).
    I can clearly see this being further developed into a Gem (that basically hooks into ```Mail.register_interceptor(TrackingMailInterceptor)```)... maybe I'll give it a shot :)
  5. Is there any way to track how much time user spent on an email?
    1. Not accurately. I guess you could analyse the time between loading an image and clicking a link, but that wouldn't give you a very good answer.
  6. That doesn't help right as user may not click any link - just wondering how the email marketing companies implement this.
    1. I didn't realise they did. What companies do? You might be able to reverse engineer it.
  7. This is pretty awesome, but the 'alt=""' might not include the the quotes, so it might be better to include the backward slashes:
    raw("")

    so it wll be:

    module MailerHelper
    def track(name, email)
    SentEmail.create!(:name => name, :email => email, :sent => DateTime.now)
    url = "#{root_path(:only_path => false)}email/track/#{Base64.encode64("name=#{name}&email=#{email}")}.png"
    raw("")
    end
    end
  8. Nice solution! May I ask why you decided to go with a Rack based solution instead of just Controller based? What is the benefit?
  9. Rack is faster as it never needs to hit the Rails stack.

Leave a comment