Action Mailbox in Rails - What is It? How to Install It?
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 inconfig/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 deployment 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.