Real Life Examples: Adding Action Mailbox to a Rails app

Find out how to install Action Mailbox in your Ruby on Rails application step by step with another part of our Real Life Examples tutorial!

In the previous article I led you through the entire process of updating your apps from Rails 5.2 to Rails 6.0. To illustrate it, I was working on a real, existing web application, called Tiomsu.com, a knowledge base software I have been developing for a while now. Having that done, let's install the first new feature of the framework's new version, called Action Mailbox.

What is Action Mailbox in Rails?

Action Mailbox is a new addition coming with Rails 6, that allows you to easily handle incoming mails. Those can be handled as incoming webhooks for services like Sendgrid, Mandrill, Mailgun or Postmark, or directly via built-in Exim, Posftix or Qmail integrations.

The scenario is simple: your users send emails to a dedicated address and your web app handles it as user input. If it's using hosted services, then the email is first received by a hosted service and then forwarded to your app via webhook. And this is on what I want to focus today.

Installing Action Mailbox in a Rails app

In tiomsu.com, I'd love to be able to handle links or notes sent by users directly to the application.

Let's start by installing ActionMailbox in the application:

$ rails action_mailbox:install
Copying application_mailbox.rb to app/mailboxes
      create  app/mailboxes/application_mailbox.rb
Copied migration 20191117101825_create_active_storage_tables.active_storage.rb from active_storage
Copied migration 20191117101826_create_action_mailbox_tables.action_mailbox.rb from action_mailbox

Migrations and mailboxes

It created two migrations and application_mailbox.rb holding the ApplicationMailbox < ActionMailbox::Base class. This is the general / top level mailbox class (think of it as of ApplicationRecord, ApplicationJob or ApplicationController) - all other mailboxes will inherit from it. It also contains routing rules, i.e. which emails should be handled by which mailboxes.

After running migrations, let's generate the first mailbox: $ rails g mailbox inbox create app/mailboxes/inbox_mailbox.rb

This command created inbox_mailbox.rb holding the InboxMailbox class (and inheriting from ApplicationMailbox) with one method - #process. This is the method that's going to be called whenever an email is routed to this ('Inbox') mailbox.

Routing emails in Action Mailbox

To 'route' an email to a specific mailbox we need to define an appropriate regexp in ApplicationMailbox. I want all emails ending with '@tiomsu.io' to be processed by InboxMailbox:

class ApplicationMailbox < ActionMailbox::Base
  routing /@tiomsu.io\Z/i => :inbox
end

tiomsu.com is the main domain, where all the "business" communication is handled, while tiomsu.io is our "supporting" domain. We use the latter solely for email parsing. This nicely separates concerns and prevents problems with email configuration.

To parse the incoming mail, let's start with the basics. Users should be able to send links or notes directly to their inbox.

class InboxMailbox < ApplicationMailbox
  before_processing :find_user

  def process
    return unless @user

    Apartment::Tenant.switch @user.account.subdomain do
      content = EmailContentExtractorCommand.new(mail).call
      EntityCreatorCommand.new(@user, title: mail.subject, content: content.result).call if content.success?
    end
  end

  private

  def find_user
    @user = User.find_by(email: mail.from)
  end
end

before_processing is the first callback called when processing the incoming mail (two others are around_processing and after_processing). We're using it to find users by email address defined in the From: field of incoming message.

In the #process method we'll silently fail (return unless @user) if a user is not present. This could be used e.g. to send bounce mail (by calling the bounce_with) method). I don't want to do that, at least not for every incoming email that does not have a corresponding user in the system.

Creating email entity

Having a user set, we need to switch a tenant / db schema and then we can create an entity from email. Before doing that (and passing the whole email content to EntityCreatorCommand command), let's extract email body in EmailContentExtractorCommand.

class EmailContentExtractorCommand
  prepend SimpleCommand
  include ActiveModel::Validations

  def initialize(mail)
    @mail = mail
  end

  def call
    if @mail.parts.present?
      @mail.parts[0].body.decoded
    else
      @mail.decoded
    end
  end
end

In #call method we're checking if this is multipart email, and if so, using its first part as a content. Otherwise let's just decode this. mail used in Mailbox and in above command is an object returned by mail gem. Consult its readme to learn more about what's happening here.

Oh, and btw, I'm using the simple_command gem to easily build service objects (commands).

Sending test emails to Action Mailbox

Having everything in place, let's send a test email to the system. The funny part with Action Mailbox is that besides exposing routes automatically, it has a few extras added, which are test endpoints where integration / mailboxes can be easily checked. To access them, just open http://localhost:3000/rails/conductor/action_mailbox/inbound_emails in your browser.

In my case (a preconfigured app upgraded to Rails 6), this didn't work out of the box. It's because the background job processor in tiomsu is already set up with a predefined set of queues. Mail processing is added to :action_mailbox_routing and :action_mailbox_incineration queues. So I can either change the queue name (i.e. to :default in config/application.rb by doing config.action_mailbox.queues.routing = :another_queue or I can alter my sidekiq.yml file (and what've decided to do):

:queues:
  - high
  - mailers
  - default
  - action_mailbox_routing
  - action_mailbox_incineration
  - low

Incineration (action_mailbox_incineration queue) is used to delete old emails from the system. By default, it's set to 30 days and it's configurable via config.action_mailbox.incinerate_after.

Security by obscurity: keeping fake users out

OK, at this moment everything should work. But there is a small problem - right now everyone can send an email to @tiomsu.io and easily spoof "From" header to add new links or notes on users' behalf. This is definitely not something we should allow. Let's add some security by obscurity here.

First of all, let's modify the accepted address. If a user is sending an email to tiomsu, they should address the account's subdomain, so that any mail sent by me, should be addressed to *@prograils.tiomsu.io:

class InboxMailbox < ApplicationMailbox
  before_processing :find_user
  before_processing :extract_secrets

  ...

  private

    ...

  def extract_secrets
    regexp = /.*@(#{SUBDOMAIN_REGEXP}).tiomsu.io/
    mail.to.each do |to_address|
      match = regexp.match(to_address)
      break if match && match[1] == @user.account.subdomain
    end
  end
end

SUBDOMAIN_REGEXP is defined in config/initializers/constants.rb like so: SUBDOMAIN_REGEXP = /[a-z][a-z0-9\-]{1,48}[a-z0-9]/

What will this do?

  • first it will fetch the user using the From: header of email (like it used to do),
  • then it will check if the email was sent to a proper account.

But this still won't be enough in my opinion. I believe that every user should have their own, unique email address to which he/she can send notes or links. That is to add a note to tiomsu, I should email it to e.g. kxelajazk6@prograils.tiomsu.io rather than to inbox@prograils.tiomsu.io.

To achieve that, I'll add a new secret field to user:

$ rails g migration AddMailboxTokenToUsers mailbox_token:string

and make it unique:

class AddMailboxTokenToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :mailbox_token, :string, index: { unique: true }
  end
end

This field should be auto-populated for each user. I prefer doing such tasks in before_save callbacks:

class User < ApplicationRecord
  # ...

  # BEFORE & AFTER
  before_create :generate_mailbox_token

  private

  def generate_mailbox_token
    loop do
      self.mailbox_token = Devise.friendly_token.first(10).downcase
      break unless User.where(mailbox_token: self.mailbox_token).exists?
    end
  end

    # ...
end

The #generate_mailbox_token is a loop that will break if there is no other user having the same mailbox_token. The token is downcased, as email addresses are case-insensitive.

And as all users should have mailbox_token filled in, then after testing above I tend to rollback migration and modify it:

class AddMailboxTokenToUsers < ActiveRecord::Migration[6.0]
  def up
    add_column :users, :mailbox_token, :string, index: { unique: true }
    User.reset_column_information
    User.find_each do |user|
      user.send(:generate_mailbox_token)
      user.save(touch: false, validate: false)
    end
  end

  def down
    remove_column :users, :mailbox_token
  end
end

As long as the database is not too big, this will do the job and upon next deploy all users will have mailbox_token generated.

So having that in place, let's modify InboxMailbox to check for mailbox_token:

class InboxMailbox < ApplicationMailbox
  before_processing :find_user
  before_processing :extract_secrets

  def process
    return unless @user
    return unless @user_verified

    Apartment::Tenant.switch @user.account.subdomain do
      content = EmailContentExtractorCommand.new(mail).call
      EntityCreatorCommand.new(@user, title: mail.subject, content: content.result).call if content.success?
    end
  end

  private

  def find_user
    @user = User.find_by(email: mail.from)
  end

  def extract_secrets
    regexp = /([[:alnum:]]+)@(#{SUBDOMAIN_REGEXP}).tiomsu.io/
    mail.to.each do |to_address|
      match = regexp.match(to_address)
      mt = match[1].downcase # emails are case insensitive
      subd = match[2].downcase
      if match && mt == @user.mailbox_token && subd == @user.account.subdomain
        @user_verified = true
        break
      end
    end
  end
end

The above code will set @user_verified only if one of the addresses defined in the "To" field will have a matching subdomain and the mailbox_token field. #process won't proceed, if @user_verified is not set.

If you wonder why I'm not extracting secret and subdomain in the first place, then the answer is that a simple #to field of every email is in fact an array and can have multiple recipients, out of which tiomsu will be only one.

Fixing the ApplicationMailbox route

Last but not least - it's super important to fix route set in ApplicationMailbox to include recent changes:

class ApplicationMailbox < ActionMailbox::Base
  routing /@#{SUBDOMAIN_REGEXP}\.tiomsu\.io\Z/i => :inbox
end

Let's configure Action Mailbox to run in production. As we're using Mailgun as SMTP gateway, let's use it also to handle incoming webhooks. First get your API key and set it in the application. It should be added in Rails credentials:

action_mailbox:
  mailgun_api_key: key-bbXXX

or via MAILGUN_INGRESS_API_KEY env variable.

Let's set up a new route in Mailgun's "Receiving" section. I had set "Custom" Expression Type with value: match_recipient(".+@.+\.tiomsu\.io") then, in Forward, I added an URL: https://tiomsu.com/rails/action_mailbox/mailgun/inbound_emails/mime

To see how other providers should be set up, consult the Action Mailbox configuration.

Also to config/environments/production.rb, add config for ingres handling, informing Rails, that we're using Mailgun: config.action_mailbox.ingress = :mailgun.

Adding Action Mailbox to a Rails app: Final remarks

Having that in place, we're basically done. To test this in local environment I had used a service similar to ngrok.io to expose local server to be visible from outside (Internet) and then I modified Mailgun forwarding config accordingly.

P.S. Action Mailbox uses Active Storage, so if you haven't used it before, you should configure it.

Recommended reads:

Contact us

* Required fields