How do I send transactional email from a Rails app in production?
Set RESEND_API_KEY and MAIL_FROM, and ActionMailer delivers through Resend in production while development keeps opening messages in a browser tab. The code side is already wired. The real work is verifying your sending domain and publishing SPF, DKIM and DMARC, without which the mail sends and lands in spam.
The two variables
MAIL_FROM has to be an address at a domain you verified with the provider. A from address at an
unverified domain is rejected at send time, which at least fails loudly.
bin/kamal app exec 'bin/rails runner "puts ActionMailer::Base.delivery_method"'
Adding an email
Use the add-mailer skill rather than writing one from scratch. It produces the mailer method, both
the HTML and text views in the shared mailer layout, and the spec, in the shape the existing mailers
use. Copying app/mailers/sign_in_code_mailer.rb by hand works too and is the same result with more
chances to miss the text part.
Send asynchronously:
SomeMailer.some_email(user).deliver_later
deliver_later enqueues through Solid Queue, which runs in the same container. No Redis, no separate
worker process, and a slow provider does not slow down the request.
Inline styles only
The mailer layout uses inline styles because email clients strip stylesheets. That is also why the
brand palette exists twice: 01-tokens.css is the source of truth and
Rails.application.config.x.brand_colors mirrors it as Ruby strings for the three things that cannot
read a stylesheet, of which email is one. A spec fails if the two disagree.
Test the text part
Every mailer ships HTML and text. A lot of clients render the text version, and it is the one nobody looks at. In development both open in the browser with a toggle, so checking takes ten seconds per mailer.
Then the DNS
This is where deliverability actually lives. SPF, DKIM and DMARC records at your DNS host, values supplied by the provider. An app with perfect mailer code and no DNS records has its sign-in emails filtered, and no amount of Ruby fixes it.