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.
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.
Comments
No comments yet. Be the first.