| name | action-mailbox |
| description | Expert guidance for receiving and processing inbound emails with Action Mailbox in Rails. Use when setting up inbound email processing, routing emails to mailboxes, configuring ingress providers (Mailgun, SendGrid, Postfix, etc.), handling attachments, bouncing emails, or testing with fixtures. Covers "action mailbox", "inbound email", "receive email", "process email", "email routing", "mailbox", "incoming email", "email processing", "ingress", "InboundEmail". |
| allowed-tools | Read, Grep, Glob, Write, Edit, Bash(bin/rails action_mailbox:*), Bash(bin/rails generate mailbox *), Bash(bin/rails db:migrate), Bash(bin/rails test:*) |
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
.eml files, 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_emails table
- Active Storage tables (if not already present)
Verify setup:
bin/rails runner "puts ActionMailbox::InboundEmail.table_exists?"
Step 2: Configure Routing
ApplicationMailbox is the router. It matches email recipients against patterns and dispatches to mailbox classes.
class ApplicationMailbox < ActionMailbox::Base
routing(/^support@/i => :support)
routing(/^reply\+(.+)@/i => :replies)
routing(/@invoices\./i => :invoices)
routing(all: => :catch_all)
end
Routing rules:
- Patterns match against
to, cc, and bcc fields
- First match wins — order matters
- Use
all: as a catch-all only if every email must be processed
- Unmatched emails stay
pending and 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
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
)
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 addresses
mail.from — array of From addresses
mail.cc — array of CC addresses
mail.subject — subject line
mail.body.decoded / mail.decoded — decoded body text
mail.date — send date
mail.message_id — unique message ID
mail.in_reply_to — message ID this replies to
mail.attachments — array of attachments
mail.multipart? — whether email has multiple parts
mail.text_part / mail.html_part — specific MIME parts
The inbound_email accessor:
inbound_email — the ActionMailbox::InboundEmail Active Record object
inbound_email.message_id — the RFC 2822 Message-ID
inbound_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.status to bounced
- Halts further processing
bounce_with UserMailer.not_authorized(inbound_email)
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_processing callbacks, not inside process
- 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
Credentials go in encrypted credentials:
bin/rails credentials:edit
action_mailbox:
ingress_password: "strong-random-password"
mailgun_signing_key: "your-mailgun-key"
mandrill_api_key: "your-mandrill-key"
Or use environment variables:
RAILS_INBOUND_EMAIL_PASSWORD — relay ingress password
MAILGUN_INGRESS_SIGNING_KEY — Mailgun signing key
MANDRILL_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:
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.action_mailbox.incinerate_after = 14.days
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
routing("support@example.com" => :support)
routing(/^reply\+(.+)@/i => :replies)
routing(/@billing\./i => :billing)
routing(all: => :catch_all)
Mailbox Class Template
class ExampleMailbox < ApplicationMailbox
before_processing :validate_sender
def process
user = User.find_by!(email_address: mail.from.first)
record = user.messages.create!(
subject: mail.subject,
body: mail.decoded,
received_at: mail.date
)
mail.attachments.each do |attachment|
record.files.attach(
io: StringIO.new(attachment.decoded),
filename: attachment.filename,
content_type: attachment.content_type
)
end
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
receive_inbound_email_from_mail(to:, from:, subject:, body:, **headers)
receive_inbound_email_from_source(source)
create_inbound_email_from_mail(to:, from:, subject:, body:)
create_inbound_email_from_source(source)
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_part or mail.html_part, not just mail.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