Rails Action Mailbox Expert
Route incoming emails to controller-like mailbox classes for processing in Rails applications.
Philosophy
Core Principles:
- Mailboxes are controllers for email — route by address pattern, process in dedicated classes
- Extract data early, incinerate later — persist domain data from emails immediately; raw emails are temporary
- Bounce explicitly — reject bad emails with informative bounce messages, don't silently swallow them
- Test with real email fixtures — use
.emlfiles, not hand-built Mail objects - One mailbox per concern — keep mailboxes focused; split routing rather than branching in
process
When To Use This Skill
- Setting up Action Mailbox in a Rails app
- Configuring an ingress provider (Mailgun, SendGrid, Postmark, Postfix, etc.)
- Writing mailbox classes to process inbound emails
- Routing emails to the correct mailbox
- Handling email attachments via Active Storage
- Bouncing or rejecting invalid emails
- Testing inbound email processing
- Debugging email delivery/processing issues
Instructions
Step 1: Install Action Mailbox
bin/rails action_mailbox:install
bin/rails db:migrate
This creates:
app/mailboxes/application_mailbox.rb— the routing hub- Migration for
action_mailbox_inbound_emailstable - Active Storage tables (if not already present)
Verify setup:
# Check the migration ran
bin/rails runner "puts ActionMailbox::InboundEmail.table_exists?"
# Check conductor is available (dev only)
# Visit http://localhost:3000/rails/conductor/action_mailbox/inbound_emails
Step 2: Configure Routing
ApplicationMailbox is the router. It matches email recipients against patterns and dispatches to mailbox classes.
# app/mailboxes/application_mailbox.rb
class ApplicationMailbox < ActionMailbox::Base
# Route by To/CC/BCC address patterns (regex or string)
routing(/^support@/i => :support)
routing(/^reply\+(.+)@/i => :replies)
routing(/@invoices\./i => :invoices)
routing(all: => :catch_all) # Fallback — use sparingly
end
Routing rules:
- Patterns match against
to,cc, andbccfields - First match wins — order matters
- Use
all:as a catch-all only if every email must be processed - Unmatched emails stay
pendingand get incinerated
⚠️ Common mistake: Forgetting that routing matches ALL recipient fields, not just to. An email CC'd to a matching address will also route.
Step 3: Generate Mailbox Classes
bin/rails generate mailbox support
Creates app/mailboxes/support_mailbox.rb:
class SupportMailbox < ApplicationMailbox
def process
# Your processing logic here
end
end
Step 4: Implement the Process Method
The process method is where you extract data from the email and persist it to your domain models.
class SupportMailbox < ApplicationMailbox
def process
ticket = SupportTicket.create!(
subject: mail.subject,
body: mail.decoded,
from_email: mail.from.first,
from_name: mail.from_address&.display_name
)
# Handle attachments
mail.attachments.each do |attachment|
ticket.files.attach(
io: StringIO.new(attachment.decoded),
filename: attachment.filename,
content_type: attachment.content_type
)
end
end
end
Key accessors on mail (a Mail::Message):
mail.to— array of To addressesmail.from— array of From addressesmail.cc— array of CC addressesmail.subject— subject linemail.body.decoded/mail.decoded— decoded body textmail.date— send datemail.message_id— unique message IDmail.in_reply_to— message ID this replies tomail.attachments— array of attachmentsmail.multipart?— whether email has multiple partsmail.text_part/mail.html_part— specific MIME parts
The inbound_email accessor:
inbound_email— theActionMailbox::InboundEmailActive Record objectinbound_email.message_id— the RFC 2822 Message-IDinbound_email.source— raw email source (RFC 822)
Step 5: Use Callbacks for Guards and Side Effects
Action Mailbox provides before_processing, after_processing, and around_processing callbacks.
class SupportMailbox < ApplicationMailbox
before_processing :ensure_known_sender
before_processing :reject_spam
after_processing :notify_team
def process
create_ticket_from_email
end
private
def ensure_known_sender
unless User.exists?(email_address: mail.from.first)
bounce_with SupportMailer.unknown_sender(inbound_email)
end
end
def reject_spam
if SpamDetector.spam?(mail)
bounce_with SupportMailer.spam_rejected(inbound_email)
end
end
def notify_team
SupportNotificationJob.perform_later(@ticket)
end
end
⚠️ Common mistake: Not understanding that bounce_with halts processing. It sets the status to bounced, sends the bounce email, and stops — the process method never runs.
Step 6: Bounce Correctly
bounce_with takes an Action Mailer message and:
- Sends the bounce email
- Sets
inbound_email.statustobounced - Halts further processing
# In a before_processing callback:
bounce_with UserMailer.not_authorized(inbound_email)
# The mailer is a normal Action Mailer:
class UserMailer < ApplicationMailer
def not_authorized(inbound_email)
@email = inbound_email
mail(
to: @email.mail.from.first,
subject: "Unable to process your email"
)
end
end
Rules for bouncing:
- Always bounce in
before_processingcallbacks, not insideprocess - Always provide a meaningful bounce email — silent drops confuse senders
- Don't bounce spam — just mark as delivered or let it incenerate silently
Step 7: Configure Ingress Provider
Set the ingress in config/environments/production.rb:
config.action_mailbox.ingress = :mailgun # or :sendgrid, :postmark, :mandrill, :relay
Credentials go in encrypted credentials:
bin/rails credentials:edit
action_mailbox:
ingress_password: "strong-random-password" # For relay/postmark/sendgrid
mailgun_signing_key: "your-mailgun-key" # For Mailgun
mandrill_api_key: "your-mandrill-key" # For Mandrill
Or use environment variables:
RAILS_INBOUND_EMAIL_PASSWORD— relay ingress passwordMAILGUN_INGRESS_SIGNING_KEY— Mailgun signing keyMANDRILL_INGRESS_API_KEY— Mandrill API key
See reference.md for provider-specific endpoint URLs and configuration details.
Step 8: Test Mailboxes
Use ActionMailbox::TestCase and its helpers:
require "test_helper"
class SupportMailboxTest < ActionMailbox::TestCase
test "creates a support ticket from inbound email" do
assert_difference "SupportTicket.count", 1 do
receive_inbound_email_from_mail(
to: "support@example.com",
from: "customer@example.com",
subject: "Help needed",
body: "I can't log in"
)
end
ticket = SupportTicket.last
assert_equal "Help needed", ticket.subject
assert_equal "customer@example.com", ticket.from_email
end
test "bounces email from unknown sender" do
inbound_email = receive_inbound_email_from_mail(
to: "support@example.com",
from: "stranger@unknown.com",
subject: "Hello"
)
assert inbound_email.bounced?
end
test "routes to support mailbox" do
assert_equal SupportMailbox, ApplicationMailbox.route(
receive_inbound_email_from_mail(to: "support@example.com")
)
end
end
Testing with .eml fixture files:
# Save real emails as test/fixtures/files/welcome_email.eml
test "processes a real email fixture" do
receive_inbound_email_from_source(
file_fixture("welcome_email.eml").read
)
end
⚠️ Common mistake: Testing with hand-crafted strings instead of realistic email fixtures. Real emails have headers, MIME boundaries, and encoding that simple strings miss. Save actual emails as .eml files.
Step 9: Use the Conductor for Local Testing
In development, visit:
http://localhost:3000/rails/conductor/action_mailbox/inbound_emails
The conductor lets you:
- Create new inbound emails manually
- View all inbound emails and their status
- Redeliver emails for reprocessing
- Test routing without configuring a real ingress
Also available via curl:
curl -X POST http://localhost:3000/rails/conductor/action_mailbox/inbound_emails \
-F "inbound_email[raw_email_file]=@path/to/email.eml"
Step 10: Configure Incineration
Processed emails are incinerated (deleted) after 30 days by default.
# config/environments/production.rb
config.action_mailbox.incinerate_after = 14.days # Override default
Important: Extract all data you need in process before relying on the raw email. After incineration, the original email and its Active Storage blob are gone.
Statuses that trigger incineration: delivered, failed, bounced
Quick Reference
InboundEmail Lifecycle
pending → processing → delivered
→ failed (exception raised)
→ bounced (bounce_with called)
Email Address Matching Patterns
# Exact local part
routing("support@example.com" => :support)
# Regex on local part
routing(/^reply\+(.+)@/i => :replies)
# Domain matching
routing(/@billing\./i => :billing)
# Catch-all (last resort)
routing(all: => :catch_all)
Mailbox Class Template
class ExampleMailbox < ApplicationMailbox
before_processing :validate_sender
def process
# 1. Find or create related records
user = User.find_by!(email_address: mail.from.first)
# 2. Extract and persist data
record = user.messages.create!(
subject: mail.subject,
body: mail.decoded,
received_at: mail.date
)
# 3. Handle attachments
mail.attachments.each do |attachment|
record.files.attach(
io: StringIO.new(attachment.decoded),
filename: attachment.filename,
content_type: attachment.content_type
)
end
# 4. Trigger side effects
NotificationJob.perform_later(record)
end
private
def validate_sender
unless User.exists?(email_address: mail.from.first)
bounce_with ExampleMailer.unknown_sender(inbound_email)
end
end
end
Test Helpers
# From ActionMailbox::TestCase / ActionMailbox::TestHelper
# Create and process an inbound email from params
receive_inbound_email_from_mail(to:, from:, subject:, body:, **headers)
# Create and process from raw RFC 822 source
receive_inbound_email_from_source(source)
# Create without processing (for routing tests)
create_inbound_email_from_mail(to:, from:, subject:, body:)
create_inbound_email_from_source(source)
# Status checks on returned InboundEmail
inbound_email.delivered?
inbound_email.bounced?
inbound_email.failed?
inbound_email.pending?
inbound_email.processing?
Anti-Patterns to Avoid
- Giant process methods — Extract to service objects; mailbox should orchestrate, not implement
- No bounce handling — Always validate sender/content before processing
- Ignoring multipart emails — Use
mail.text_partormail.html_part, not justmail.body - Relying on raw email after incineration — Extract data into domain models immediately
- Catch-all without filtering —
routing(all:)catches spam too; filter aggressively - Testing only happy path — Test bounces, unknown senders, missing fields, spam
- Hardcoding ingress passwords — Use credentials or environment variables
- Processing in callbacks — Use callbacks for guards only; business logic goes in
process - Not setting up Active Job — Action Mailbox routes asynchronously via Active Job; ensure your queue adapter works
- Forgetting Active Storage — Action Mailbox stores raw emails via Active Storage; both must be migrated