action-mailbox
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
More from thinkoodle/rails-skills
caching
Expert guidance for Rails caching — fragment caching, Russian doll caching, cache keys/versioning, low-level caching (Rails.cache), conditional GET (stale?/fresh_when), and cache stores (Solid Cache, Redis, Memcached). Use when implementing cache, caching, fragment cache, Russian doll, Rails.cache, Solid Cache, cache key, HTTP caching, stale?, fresh_when, cache store, or optimizing performance.
4uuid-primary-keys
Expert guidance for implementing UUID primary keys in Rails applications. Use when setting up UUIDs as primary keys, choosing between UUIDv4 and UUIDv7, configuring generators for UUID defaults, writing migrations with id colon uuid, adding UUID foreign keys, implementing base36 encoding for URL-friendly IDs, configuring PostgreSQL pgcrypto or gen_random_uuid, implementing SQLite binary UUID storage, choosing a primary key type, using non-sequential IDs, secure IDs, random IDs, or any ID generation strategy beyond auto-increment integers.
4stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4routing
Expert guidance for defining routes in Rails applications. Use when adding routes, working with resources, nested routes, namespaces, path helpers, routes.rb, RESTful design, API routes, URL helpers, or any routing-related task. Covers resources, singular resources, nesting, namespace vs scope, constraints, concerns, member/collection routes, and route testing.
4