Rails 8 Action Mailbox: Process Incoming Email

Processing incoming emails opens powerful workflows: support tickets from customer replies, document uploads via email attachments, or automated data imports. Rails 8 Action Mailbox provides a complete framework for receiving, routing, and processing inbound emails with the same elegance Rails brings to everything else.

Setting Up Action Mailbox

Action Mailbox requires a few migrations and configuration steps. Start by installing the framework:

# Terminal
bin/rails action_mailbox:install
bin/rails db:migrate

This creates two tables: action_mailbox_inbound_emails for storing raw emails and tracking processing status, and the Active Storage tables for attachments. Configure the ingress (how emails arrive) in the environment file:

# config/environments/production.rb
config.action_mailbox.ingress = :mailgun

Available ingress options include :mailgun, :sendgrid, :postmark, :mandrill, and :relay for direct SMTP. For development, use the conductor interface at /rails/conductor/action_mailbox/inbound_emails to test with manually crafted emails.

Routing Incoming Emails

Action Mailbox routes emails to specific mailboxes based on rules defined in the ApplicationMailbox. The routing DSL matches against recipient addresses:

# app/mailboxes/application_mailbox.rb
class ApplicationMailbox < ActionMailbox::Base
  # Route support emails to the support mailbox
  routing /^support@/i => :support
  
  # Route replies with tokens to the reply mailbox
  routing /^reply\+(.+)@/i => :reply
  
  # Route document submissions
  routing "[email protected]" => :documents
  
  # Route everything else to a catch-all
  routing :all => :default
end

Routes are evaluated in order, so place specific patterns before catch-alls. The routing accepts strings for exact matches, regular expressions for patterns, and procs for complex logic:

# app/mailboxes/application_mailbox.rb
class ApplicationMailbox < ActionMailbox::Base
  routing ->(inbound_email) { 
    inbound_email.mail.to.any? { |addr| addr.include?("urgent") }
  } => :urgent
  
  routing :all => :default
end

Building a Support Ticket Mailbox

Create mailboxes to handle specific email types. A support ticket mailbox might create tickets from customer emails and attach any included files:

# app/mailboxes/support_mailbox.rb
class SupportMailbox < ApplicationMailbox
  before_processing :ensure_valid_sender
  
  def process
    ticket = create_or_update_ticket
    attach_files(ticket)
    notify_support_team(ticket)
  end
  
  private
  
  def create_or_update_ticket
    existing_ticket = find_existing_ticket
    
    if existing_ticket
      existing_ticket.replies.create!(
        body: email_body,
        sender_email: sender_email
      )
      existing_ticket
    else
      SupportTicket.create!(
        subject: mail.subject || "No Subject",
        body: email_body,
        sender_email: sender_email,
        sender_name: sender_name,
        status: :open
      )
    end
  end
  
  def find_existing_ticket
    # Check for In-Reply-To header to find existing conversation
    if mail.in_reply_to.present?
      message_id = mail.in_reply_to.gsub(/[<>]/, "")
      SupportTicket.find_by(message_id: message_id)
    end
  end
  
  def attach_files(ticket)
    mail.attachments.each do |attachment|
      ticket.files.attach(
        io: StringIO.new(attachment.decoded),
        filename: attachment.filename,
        content_type: attachment.content_type
      )
    end
  end
  
  def notify_support_team(ticket)
    SupportMailer.new_ticket_notification(ticket).deliver_later
  end
  
  def email_body
    # Prefer plain text, fall back to stripped HTML
    if mail.multipart?
      mail.text_part&.decoded || strip_html(mail.html_part&.decoded) || ""
    else
      mail.decoded
    end
  end
  
  def strip_html(html)
    return nil unless html
    ActionController::Base.helpers.strip_tags(html).squish
  end
  
  def sender_email
    mail.from&.first
  end
  
  def sender_name
    mail[:from]&.display_names&.first || sender_email
  end
  
  def ensure_valid_sender
    bounced! unless sender_email.present?
  end
end

The mail object is a Mail::Message instance providing access to all email headers, body parts, and attachments. The lifecycle callbacks (before_processing, after_processing) and status methods (bounced!, delivered!) control how emails move through the system.

Processing Reply Tokens

A common pattern uses tokenized reply addresses to associate responses with specific records. When sending outbound emails, include a reply token in the from address:

# app/mailers/comment_mailer.rb
class CommentMailer < ApplicationMailer
  def notification(comment)
    @comment = comment
    @post = comment.post
    
    mail(
      to: @post.author.email,
      subject: "New comment on #{@post.title}",
      reply_to: reply_address(@post)
    )
  end
  
  private
  
  def reply_address(post)
    token = post.signed_id(purpose: :email_reply, expires_in: 30.days)
    "reply+#{token}@example.com"
  end
end

The reply mailbox extracts and validates the token:

# app/mailboxes/reply_mailbox.rb
class ReplyMailbox < ApplicationMailbox
  before_processing :set_post
  before_processing :set_user
  
  def process
    @post.comments.create!(
      user: @user,
      body: email_body
    )
  end
  
  private
  
  def set_post
    token = recipient_token
    @post = Post.find_signed(token, purpose: :email_reply)
    bounced! unless @post
  rescue ActiveSupport::MessageVerifier::InvalidSignature
    bounced!
  end
  
  def set_user
    @user = User.find_by(email: mail.from&.first)
    bounced! unless @user
  end
  
  def recipient_token
    # Extract token from [email protected]
    mail.to.find { |addr| addr.start_with?("reply+") }
        &.then { |addr| addr[/reply\+(.+)@/, 1] }
  end
  
  def email_body
    # Strip quoted reply text
    body = mail.text_part&.decoded || mail.decoded
    EmailReplyTrimmer.trim(body)
  end
end

Using signed_id with a purpose and expiration ensures tokens cannot be forged or reused for unintended purposes.

Testing Action Mailbox

Action Mailbox includes testing helpers for RSpec and Minitest. Test mailboxes by creating inbound emails and asserting on outcomes:

# spec/mailboxes/support_mailbox_spec.rb
require "rails_helper"

RSpec.describe SupportMailbox, type: :mailbox do
  include ActionMailbox::TestHelper
  
  describe "#process" do
    it "creates a support ticket from incoming email" do
      expect {
        receive_inbound_email_from_mail(
          from: "[email protected]",
          to: "[email protected]",
          subject: "Help needed",
          body: "Cannot login to my account"
        )
      }.to change(SupportTicket, :count).by(1)
      
      ticket = SupportTicket.last
      expect(ticket.sender_email).to eq("[email protected]")
      expect(ticket.subject).to eq("Help needed")
      expect(ticket.body).to include("Cannot login")
    end
    
    it "attaches files from the email" do
      receive_inbound_email_from_mail(
        from: "[email protected]",
        to: "[email protected]",
        subject: "Screenshot attached",
        body: "See attached"
      ) do |mail|
        mail.add_file(filename: "screenshot.png", content: file_fixture("test.png").read)
      end
      
      ticket = SupportTicket.last
      expect(ticket.files).to be_attached
      expect(ticket.files.first.filename.to_s).to eq("screenshot.png")
    end
    
    it "bounces emails without a sender" do
      inbound_email = receive_inbound_email_from_mail(
        from: "",
        to: "[email protected]",
        subject: "Spam"
      )
      
      expect(inbound_email).to have_been_bounced
    end
  end
end

The receive_inbound_email_from_mail helper creates and processes an inbound email in one step. For more control, use create_inbound_email_from_mail to create without processing.

Handling Edge Cases

Production email processing encounters malformed messages, spam, and unexpected encodings. Build defensive mailboxes:

# app/mailboxes/application_mailbox.rb
class ApplicationMailbox < ActionMailbox::Base
  rescue_from ActiveRecord::RecordInvalid, with: :handle_invalid_record
  rescue_from StandardError, with: :handle_unexpected_error
  
  private
  
  def handle_invalid_record(exception)
    Rails.logger.error("Mailbox validation failed: #{exception.message}")
    bounced!
  end
  
  def handle_unexpected_error(exception)
    Rails.logger.error("Mailbox processing failed: #{exception.message}")
    Rails.error.report(exception, handled: true)
    bounced!
  end
end

Consider rate limiting by sender, scanning attachments for malware before processing, and setting maximum attachment sizes in Active Storage configuration.

Summary

Action Mailbox transforms Rails applications into email-processing powerhouses. The routing DSL directs emails to specialized mailboxes, callbacks control the processing lifecycle, and signed tokens enable secure reply handling. Combined with Active Storage for attachments and Solid Queue for async processing, email becomes just another input channel for Rails applications.

For applications already using Action Mailer for outbound email, Action Mailbox completes the circleβ€”enabling true two-way email communication with minimal configuration and maximum Rails conventions.

10 claps
← Back to Blog