LaunchKit
← All posts
· 5 min read · by The LaunchKit team · 3 views

Receiving email in Rails with Action Mailbox

Receiving email is one of those features that looks like it needs a background daemon, an IMAP connection and a parser you'll regret. It doesn't. Action Mailbox gives you a router, a class per kind of message, and a test helper that constructs an email in one line.

What follows was measured on Rails 8.1.4 with action_mailbox:install and nothing else.

The router you already know

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

class SupportMailbox < ApplicationMailbox
  def process
    Ticket.create!(from: mail.from.first, subject: mail.subject, body: mail.decoded)
  end
end

The regular expression runs against the recipient, and the first match wins, exactly like config/routes.rb. :all is the fallback and you want it: without one, mail to an address you didn't anticipate has nowhere to go.

Case doesn't bite you here, as long as your regex says so. We sent to SUPPORT@EXAMPLE.COM with the /i flag on and it routed to SupportMailbox. Drop the flag and it won't, and email addresses arrive in whatever case the sender's client felt like using.

Inside process, mail is a Mail::Message, which is the same object Action Mailer builds on, so mail.from, mail.subject, mail.attachments and mail.decoded are all there.

Five statuses, and one of them is a decision

ActionMailbox::InboundEmail.statuses.keys
# => ["pending", "processing", "delivered", "failed", "bounced"]

An email you create in a test arrives as processing and finishes delivered once routing succeeds. The two interesting ones are the other two.

failed is what happens when your process method raises. The record stays, the exception reaches your error tracker, and you can replay it after you've fixed the bug, which is most of the reason to have a table at all.

bounced is different, because it's something you choose:

class SupportMailbox < ApplicationMailbox
  before_processing :require_known_sender

  def process = Ticket.create!(...)

  private

  def require_known_sender
    bounce_with(SupportMailer.unknown_sender(inbound_email)) unless User.exists?(email: mail.from.first)
  end
end

We measured what that leaves behind: status bounced, and the record still in the table. Nothing raised. That's deliberate and it's the right shape, because a bounce is a normal outcome you'll want to count later, not an error.

An inbound email traced through Action Mailbox. It arrives at one of five mounted ingress routes, is stored with its raw source as an Active Storage attachment and its message id recorded, and enters the status pending. Routing moves it to processing and matches the recipient against the application mailbox, which sends it either to a named mailbox or to the catch all. Three outcomes follow: process returns and the status becomes delivered, process raises and the status becomes failed with the record kept for replay, or the mailbox calls bounce_with and the status becomes bounced with the record also kept. A final box shows every one of those records being deleted thirty days later by the incineration job.

The storage nobody mentions

Every inbound email is stored whole, as an Active Storage attachment. Our one line test message kept 234 bytes of source, and real mail with a PDF on it keeps the PDF.

Two things follow. You need Active Storage configured before Action Mailbox will run at all, which is the first error most people hit. And the table grows at the rate your inbox receives, including the spam, because routing happens after storage.

Rails already thought about that:

ActionMailbox.incinerate        # => true
ActionMailbox.incinerate_after  # => 30 days

Thirty days after processing, a job deletes the record and the blob. That's a sensible default and it's also a surprise if you assumed you had an archive. If support tickets need to keep their original email, copy what you need out during process, because the source won't be there in two months.

If you need the opposite, config.action_mailbox.incinerate = false keeps everything, and then the retention policy is yours to write and yours to explain to whoever asks about personal data.

The endpoints that are already mounted

bin/rails routes | grep mailbox on a fresh install shows five POST endpoints you didn't add, for Postmark, SendGrid, Mailgun, Mandrill and a generic relay.

That looks alarming and isn't. We POSTed a well formed message to the relay endpoint with no credentials and no ingress configured, and got 404 with zero records created. The routes exist, the controllers refuse to act until config.action_mailbox.ingress names one of them, and once it does the relay authenticates with HTTP basic against action_mailbox.ingress_password from your credentials.

Which is the one thing to get right in production. That password is the only thing between the public internet and your process method, so it belongs in credentials rather than in a config file, and the endpoint belongs behind HTTPS, because basic auth over plaintext is the password written on a postcard.

What this page doesn't cover

No real provider. Everything above used create_inbound_email_from_mail, so nothing here tells you what Postmark's payload looks like, how SendGrid's parse webhook differs, or what happens when a provider retries a delivery it thinks failed. That last one is what message_id is for and we never exercised it.

Attachments got stored and never opened. Parsing a reply out of a quoted email thread, stripping signatures, and handling the forty ways a client can structure a multipart message are the actual work of an inbound email feature, and mail.decoded is where that work starts rather than ends.

#rails #email #active-storage

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.