35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Action Mailbox: Processing Inbound Emails in Production with Postmark, Mailgun and SendGrid

Rails Action Mailbox: Processing Inbound Emails in Production with Postmark, Mailgun and SendGrid

Roger Heykoop
Ruby on Rails, DevOps
Rails Action Mailbox guide: process inbound emails in production with Postmark, Mailgun, SendGrid; route by domain, attach files, parse replies, beat spam.

A logistics startup hired me last spring to ship a feature their CEO had been promising customers for nine months: forward your supplier’s order confirmation email to orders@theircompany.com, and the system would create a draft purchase order with the line items pre-filled. The previous developer had wired this through a Zapier-into-Make.com-into-a-webhook contraption that lost roughly one in twenty emails, occasionally double-processed others on Mondays, and cost them more than their entire Postgres bill. I rewrote it on Rails Action Mailbox in four days. They have not lost an email since.

After nineteen years of Rails I have seen this pattern more times than I can count. Inbound email is the unglamorous backbone of half the B2B software you use. Support tickets, expense receipts, replies to notifications, document drops, parsing newsletters into a feed — it all flows through somebody’s whatever@yourdomain.com. The Rails Action Mailbox framework, shipped in Rails 6 and refined through Rails 8, is the production-grade primitive for this work. This post is the playbook I use with clients: how to set it up, how to wire it to Postmark, Mailgun, or SendGrid, how to route messages, parse attachments and replies, and how to keep spam, abuse, and your own bugs from ruining your week.

What Rails Action Mailbox Actually Does

Rails Action Mailbox receives inbound emails the way Action Mailer sends them. Your provider — Postmark, Mailgun, SendGrid, Mandrill, or Postfix on your own box — accepts SMTP traffic, converts each message to a webhook payload, and POSTs it to a single Rails endpoint. Action Mailbox parses the payload, persists the raw email and a structured ActionMailbox::InboundEmail record, and dispatches it to the right mailbox class based on routing rules you define.

You write classes that look like this:

class SupportMailbox < ApplicationMailbox
  def process
    Ticket.create_from_email!(mail)
  end
end

And you describe how mail finds them:

class ApplicationMailbox < ActionMailbox::Base
  routing /^support@/i  => :support
  routing /^orders@/i   => :orders
  routing :all          => :catch_all
end

Everything else — webhook signing, retries, bounce handling, raw email storage, deletion after a configurable retention window — is handled by Active Storage and Active Job. It is one of those Rails features that looks small until you try to build it yourself, then you suddenly understand why the framework is worth the price.

Setting Up Rails Action Mailbox

Action Mailbox is included in Rails. You enable it with one command:

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

This creates app/mailboxes/application_mailbox.rb, adds two tables — action_mailbox_inbound_emails and Active Storage’s active_storage_blobs if not already present — and mounts the routing engine. The raw RFC 822 email is stored as an Active Storage blob, which means it lives wherever your other uploads live (S3, GCS, the local disk in development).

Configure the ingress in config/environments/production.rb:

config.action_mailbox.ingress = :postmark

Or :mailgun, :sendgrid, :mandrill, :postfix, :relay. Each ingress has its own webhook signing scheme. Then set the shared password in encrypted credentials — never in environment variables sitting in a CI dashboard:

bin/rails credentials:edit
action_mailbox:
  ingress_password: a-long-random-string-generated-with-secure-random
postmark:
  api_token: server-token-from-postmark

If you have not moved your secrets to encrypted credentials yet, the post on Rails credentials and secrets management walks through it. Do that first; it is twenty minutes of work that pays off forever.

Wiring Postmark for Inbound Email

Postmark is what I install for ninety percent of clients. The pricing is honest, the deliverability is excellent, and inbound parsing is bundled with the same server you use for transactional outbound. Set it up like this:

  1. In the Postmark dashboard, go to your Server, Settings, and find the inbound stream.
  2. Set the inbound webhook URL to https://yourapp.com/rails/action_mailbox/postmark/inbound_emails.
  3. Set HTTP basic auth username to actionmailbox and password to the ingress_password you generated.
  4. Set the inbound forwarding domain to something like inbound.yourdomain.com — this is the address customers email — or use the auto-generated …@inbound.postmarkapp.com while developing.

That is it. A test send to support@inbound.yourdomain.com will land in your SupportMailbox#process within a few seconds. If it does not, the answer is almost always one of three things: wrong webhook password, wrong DNS for the inbound domain (Postmark expects an MX record pointing at inbound.postmarkapp.com), or your firewall is blocking Postmark’s IPs.

Wiring Mailgun for Inbound Email

Mailgun is the right choice when you are already on Mailgun for outbound or when you need flexible regex-based receive routes. Configuration:

# config/environments/production.rb
config.action_mailbox.ingress = :mailgun
# encrypted credentials
action_mailbox:
  mailgun_signing_key: <your mailgun http webhook signing key>

In Mailgun, create a route with match_recipient(".*@inbound.yourdomain.com") and an action of forward("https://yourapp.com/rails/action_mailbox/mailgun/inbound_emails/mime"). Note the /mime suffix — Mailgun has two webhook formats and only the MIME one is what Action Mailbox expects.

Mailgun signs each webhook with HMAC-SHA256 and Action Mailbox verifies the timestamp, token, and signature for you. If you see 401s in your logs the signing key is wrong; rotate it carefully because Mailgun does not let you have two active at once.

Wiring SendGrid for Inbound Email

SendGrid’s Inbound Parse is functional but the most fiddly of the three. You configure it under Settings → Inbound Parse, point an MX record at mx.sendgrid.net, and forward to https://actionmailbox:PASSWORD@yourapp.com/rails/action_mailbox/sendgrid/inbound_emails.

Two SendGrid quirks to know. First, attachments come in a separate set of multipart fields, and very large messages (>30MB) are silently truncated unless you have an enterprise plan. Second, SendGrid has no real signing for inbound webhooks — it relies on the basic auth password — so use a long random one and rotate it if you ever leak it.

For all three providers, document your runbook in your repo: which provider, where the webhook is, where the password is, and how to roll the credential. The post on building runbooks your team can actually use covers the pattern.

Routing Inbound Emails to the Right Mailbox

The routing DSL handles most of what you need:

class ApplicationMailbox < ActionMailbox::Base
  routing /^support@/i        => :support
  routing /^orders@/i         => :orders
  routing /^(invoice|bill)@/i => :billing
  routing /\+([0-9]+)@/i      => :ticket_reply  # matches user+12345@yourdomain.com
  routing :all                => :catch_all
end

Routes evaluate in order, so put the specific patterns above the catch-all. The capture groups in the regex are not exposed automatically — if you need the captured ticket id, do it inside the mailbox:

class TicketReplyMailbox < ApplicationMailbox
  def process
    ticket_id = mail.to.first[/\+([0-9]+)@/, 1]
    ticket = Ticket.find_by(id: ticket_id)
    return bounce_with(TicketMailer.unknown_ticket(mail)) unless ticket

    ticket.add_reply!(
      from: mail.from.first,
      body: parse_reply(mail),
      attachments: mail.attachments
    )
  end

  private

  def parse_reply(mail)
    body = mail.html_part&.decoded || mail.text_part&.decoded || mail.body.decoded
    EmailReplyParser.parse_reply(body)
  end
end

The email_reply_parser gem (the same one GitHub uses) strips signatures and quoted reply text. Pair it with Loofah to sanitize the HTML before storage and you have a robust reply pipeline for support tickets, comments-by-email, or any conversational feature.

Parsing Attachments and Storing Them Safely

The mail gem inside Action Mailbox exposes attachments as Mail::Part objects. Pushing them to Active Storage is straightforward:

class OrdersMailbox < ApplicationMailbox
  ALLOWED_TYPES = %w[application/pdf image/jpeg image/png application/vnd.ms-excel].freeze
  MAX_BYTES = 25.megabytes

  def process
    order = Order.create!(supplier_email: mail.from.first, subject: mail.subject)

    mail.attachments.each do |attachment|
      next unless ALLOWED_TYPES.include?(attachment.mime_type)
      next if attachment.body.encoded.bytesize > MAX_BYTES

      order.documents.attach(
        io: StringIO.new(attachment.body.decoded),
        filename: attachment.filename,
        content_type: attachment.mime_type
      )
    end

    OrderExtractionJob.perform_later(order)
  end
end

Three things this does that toy examples miss. It enforces an MIME-type allowlist using the parsed content type, not the filename — users send .pdf files that are actually executables, ZIPs, or Word docs more often than you would believe. It caps attachment size at the controller level rather than relying on your provider’s limit. And it pushes the heavy work — running the document through Claude or your OCR service — to a background job rather than blocking the webhook response.

For the logistics startup, the next step in OrderExtractionJob calls the Anthropic Vision API. The full pattern is in Rails Claude Vision API for PDF receipt extraction. Combining inbound email with vision extraction is one of the highest-leverage patterns I deploy in 2026 — it replaces an entire data-entry team with two hundred lines of Ruby.

Bouncing, Forwarding and Catch-All

Not every inbound email should be processed. Action Mailbox gives you bounce_with, bounced!, and delivered! callbacks to model the lifecycle:

class SupportMailbox < ApplicationMailbox
  before_processing :ensure_known_sender

  def process
    SupportTicket.create_from_email!(mail)
  end

  private

  def ensure_known_sender
    return if Customer.exists?(email: mail.from.first)

    bounce_with SupportMailer.unknown_sender(mail)
  end
end

bounce_with sends a polite reply to the original sender — typically a Mailer that says “we don’t recognize this address, please email from your registered email” — and marks the inbound email as :bounced. The original message is still stored, so you can investigate later. Catch-all routes are for everything that did not match any other rule. I usually have mine forward to a humans-only review queue rather than dropping silently:

class CatchAllMailbox < ApplicationMailbox
  def process
    InboundEmailReview.create!(
      from: mail.from.first,
      to: mail.to.first,
      subject: mail.subject,
      raw_email: inbound_email
    )
    AdminMailer.unrouted_email(self).deliver_later
  end
end

This has saved me on three separate client engagements where someone changed the address on a printed contract from support@ to help@ and we would have silently dropped a year of customer mail.

Defending Against Spam, Abuse and Loops

A public inbound email endpoint is a magnet for trouble. Three defenses belong in every Action Mailbox app:

  1. SPF/DKIM verification at the provider. Postmark, Mailgun, and SendGrid all do this and pass the result through. Reject in before_processing if the message failed both:

    before_processing :require_authenticated_sender
    
    def require_authenticated_sender
      return if mail.header["Authentication-Results"]&.value&.match?(/dkim=pass|spf=pass/)
      bounced!
    end
    
  2. Rate-limit per sender. A spammy sender will hit you a thousand times an hour. Use Rack::Attack at the route level or a Redis counter inside before_processing. The post on Rails rate limiting with Rack::Attack covers the patterns.

  3. Detect and break loops. Auto-reply mailers replying to auto-reply mailers is a classic infinite loop. Skip processing if the message has Auto-Submitted: auto-replied or Precedence: bulk headers, and never reply with a Mailer that itself can be inbound-routed back into your app.

Add a feature flag too. The post on Rails feature flags with Flipper shows how to wire a kill switch you can flip in five seconds when an attacker finds your address.

Testing Action Mailbox in Practice

The receive_inbound_email_from_mail test helper makes mailbox tests fast and deterministic:

require "test_helper"

class OrdersMailboxTest < ActionMailbox::TestCase
  test "creates an order with attachments" do
    inbound = receive_inbound_email_from_mail(
      to: "orders@inbound.example.com",
      from: "supplier@vendor.com",
      subject: "PO #4421",
      body: "See attached.",
      attachments: { "po.pdf" => file_fixture("po.pdf").read }
    )

    assert_equal :delivered, inbound.status.to_sym
    assert_equal 1, Order.count
    assert_equal 1, Order.last.documents.count
  end

  test "rejects oversized attachments" do
    inbound = receive_inbound_email_from_mail(
      to: "orders@inbound.example.com",
      from: "supplier@vendor.com",
      subject: "PO #4421",
      body: "See attached.",
      attachments: { "huge.pdf" => "x" * 30.megabytes }
    )

    assert_equal 0, Order.last.documents.count
  end
end

Test the routing as well, with assert_inbound_email_routes_to. And run the full pipeline at least once a week in CI by sending a real message to a sandbox domain — provider configuration drifts, MX records rot, and DNS issues only show up when actual SMTP traffic hits. Synthetic checks are cheap insurance.

When Not to Use Action Mailbox

Honesty time. Action Mailbox is the right tool when email is a feature, not a transport mechanism. Cases where it is wrong:

  • Bulk mailing list ingest. Millions of opt-in subscriptions a day belong in a dedicated pipeline, not Active Job. Postmark, Mailgun, and SendGrid will rate-limit you anyway.
  • Real-time chat. The latency from SMTP to webhook is two to thirty seconds. Use a real chat protocol.
  • Compliance-heavy archival. If regulators require WORM-style retention with full chain-of-custody, you want a dedicated email archive product, not Active Storage with a thirty-day Action Mailbox retention.

For the logistics startup the fit was perfect, and we shipped it in four days. They handle three thousand inbound emails a day across orders, support, and replies-to-notifications. The Action Mailbox table is purged after thirty days, the structured records persist forever, and the CEO has stopped apologizing to customers about the “system that loses emails sometimes.” That is the win.

FAQ

How does Rails Action Mailbox handle attachments larger than the email body limit?

Action Mailbox stores the raw email as an Active Storage blob, so the size limit is whichever is smaller: your storage backend’s per-object cap (5GB on S3, much larger than any email) and the limit your provider imposes on inbound messages. Postmark caps at 35MB, Mailgun at 25MB, SendGrid at 30MB on most plans. Enforce your own attachment-size limit inside the mailbox before persisting to Active Storage to keep storage costs predictable.

Can I use Rails Action Mailbox without a third-party provider?

Yes — the :postfix and :relay ingresses let you receive directly from your own SMTP server. I do not recommend it. Operating an inbound MX with proper SPF, DKIM, DMARC, IP reputation, and abuse handling is a full-time job. Pay Postmark or Mailgun the few dollars a month and put your engineering hours into your product.

How do I parse just the new content from a reply email in Rails?

Use the email_reply_parser gem. It strips quoted text, signatures, and On Tue, ... wrote: headers from a body, returning just the new content the user typed. Combine it with Loofah to sanitize HTML and you have GitHub-grade reply parsing in five lines. For trickier cases (Outlook’s quoting style is uniquely awful), consider the talon Python service via a small HTTP wrapper.

How long does Action Mailbox keep inbound emails?

By default, processed emails are incinerated after thirty days, configurable via config.action_mailbox.incinerate_after. Bounced or failed emails are kept indefinitely so you can investigate. For GDPR or CCPA compliance, set the retention to match your privacy policy and document the deletion job in your data flow register. Do not lengthen retention without a reason — every retained email is a liability.

Need help wiring inbound email, AI extraction, or any production Rails feature into your product? TTB Software builds these every month for clients across Europe. We have been doing Rails for nineteen years and we treat email like the production-critical infrastructure it is.

#rails-action-mailbox #rails-inbound-email #action-mailbox-postmark #action-mailbox-mailgun #action-mailbox-sendgrid #ruby-on-rails
R

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation