posthaste-rails

An ActionMailer delivery method for the Posthaste transactional email API.

If your application already sends mail with ActionMailer, the whole migration is two lines of configuration. Every mail() call, every view, every deliver_later, every assert_emails in your test suite stays exactly as it is.

# Gemfile
gem 'posthaste-rails'
# config/environments/production.rb
config.action_mailer.delivery_method = :posthaste
config.action_mailer.posthaste_settings = { api_key: ENV['POSTHASTE_API_KEY'] }

That is the change. Nothing else in the application moves.

Requires Ruby 3.1+ and ActionMailer 6.1+. Its only dependency is ActionMailer itself — the HTTP is Net::HTTP and the JSON is the stdlib, so it adds nothing to your lockfile that Rails was not already carrying.

Why this and not SMTP

Posthaste runs an SMTP relay, and ActionMailer can point at it today with no gem at all. That is a perfectly good answer, so here is the honest comparison rather than a pitch.

Reach for When
The SMTP relay You are on an ordinary server or container, port 587 is open outbound, and you send ordinary mail — including things this adapter cannot express, like calendar invitations.
This gem Outbound SMTP is blocked or throttled where you run. Most serverless and container platforms either block 25/587 outright or make a long-lived connection expensive; an HTTPS request has neither problem.
This gem You want the fields SMTP has nowhere to put: streams, tags, metadata, stored templates, an idempotency key, scheduled sending.
This gem You want to branch on why a message was refused. SMTP gives you a three-digit code and a sentence.

Settings

Everything is optional except the key, which falls back to POSTHASTE_API_KEY.

config.action_mailer.posthaste_settings = {
  api_key:         ENV['POSTHASTE_API_KEY'],
  base_url:        'https://api.posthastemail.dev', # self-hosting? point it here
  stream:          'transactional',                 # default for every message
  tags:            %w[rails],
  metadata:        { app: 'acme' },
  open_timeout:    10,
  read_timeout:    30,
  max_retries:     2,
  max_retry_delay: 60.0,
  on_warning:      ->(w) { Rails.logger.warn("[posthaste] #{w}") },
  logger:          Rails.logger,
}

An unrecognised setting raises Posthaste::ConfigurationError naming it, rather than being ignored — a typo like api_kye: is otherwise a silent fallback to the environment variable, or to no key at all.

base_url is the whole story for a self-hosted install: point it at your own API host and everything else works unchanged.

Reading the result

deliver_now hands back the Mail::Message, as it always has. The API's answer is attached to it:

mail = InvoiceMailer.issued(invoice).deliver_now

mail.posthaste_result.id           # 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ'
mail.posthaste_result.status       # 'queued' | 'scheduled' | 'duplicate'
mail.posthaste_result.duplicate?   # an idempotency replay: no new message exists
mail.posthaste_result.suppressed   # recipients skipped, and why
mail.posthaste_result.warnings     # the platform's pre-send lint findings
mail.posthaste_result.mapping_warnings # what this gem had to change

#posthaste_result is the only method this gem adds to a class it does not own. It returns nil for a message delivered any other way.

The fields ActionMailer has no word for

Streams, tags, metadata, idempotency keys, stored templates and scheduled sends are set with X-Posthaste-… headers. mail() already passes any unrecognised key straight through as a header, so this needs no new API and works in every Rails version:

def issued(invoice)
  mail(
    to: invoice.email,
    subject: "Invoice #{invoice.number}",
    'X-Posthaste-Stream' => 'transactional',
    'X-Posthaste-Tags' => 'invoice,billing',
    'X-Posthaste-Metadata' => { invoice_id: invoice.id }.to_json,
    'X-Posthaste-Idempotency-Key' => "invoice-#{invoice.id}",
  )
end
Header Becomes Notes
X-Posthaste-Stream stream Defaults to the settings-level stream.
X-Posthaste-Tags tags Comma-separated.
X-Posthaste-Metadata metadata A JSON object. Values are coerced to strings.
X-Posthaste-Idempotency-Key idempotencyKey See Retries below.
X-Posthaste-Template template The stored template supplies the body.
X-Posthaste-Template-Version templateVersion An integer.
X-Posthaste-Variables variables A JSON object.
X-Posthaste-Scheduled-At scheduledAt RFC 3339, e.g. 2026-09-01T09:00:00Z.

They are consumed: none of them reach the wire as ordinary headers. A mistyped one (X-Posthaste-Steam) raises rather than being forwarded as an inert custom header, because the alternative is a send that quietly ignores the stream it was told to use.

What maps, what changes, what is refused

ActionMailer hands the delivery method a fully-composed Mail::Message — a rich RFC 5322 document. The send API takes fields. The governing rule for the gap between them is that nothing is dropped in silence: a team that migrates and does not notice their Bcc stopped arriving is the outcome this design exists to prevent.

Mapped, unchanged

ActionMailer Becomes
from from, display name kept
to, cc, bcc to, cc, bcc — each recipient is its own message and counts as one send
reply_to replyTo, display names kept, several addresses kept
subject subject
text and HTML templates text and html, found wherever the message put them — including multipart/mixed[ multipart/alternative[…], attachment ], which is what Rails builds for two templates plus a file
a single-template, non-multipart message text, or html if its Content-Type says so
attachments[…] attachments, base64-encoded, with the declared content type
attachments.inline[…] attachments with disposition: inline and the cid kept, so <img src="cid:…"> still resolves
List-Unsubscribe listUnsubscribe
any other header headers

Mapped, with a warning

Warnings reach on_warning and mail.posthaste_result.mapping_warnings, so you find out on the first send rather than in a support ticket.

What What happens Why
Display names on to / cc / bcc The name is dropped; the address is unchanged. The API addresses recipients by bare address. This is the one place the mapping loses something a recipient could have seen — and it is only how their own address is labelled in their own client, which most clients override from the address book anyway. Refusing would break the migration for nearly every application that has ever set a recipient name.
A body whose declared charset does not decode Unreadable bytes are replaced. The message still goes; the damage is confined to bytes that were already unreadable.
X-Posthaste-Template alongside a rendered view The template supplies the content and the view is not sent. Sending both would make the API pick one, and whichever it picked would be a surprise.

Composed by the platform, and dropped without a warning

Date, Message-ID, MIME-Version, Content-Type, Content-Transfer-Encoding, Content-Disposition, List-Unsubscribe-Post.

Mail stamps every one of these onto every message before a delivery method ever sees it, so a warning here would fire on every send an application ever made — which is exactly how the warnings that matter get ignored. The platform composes the MIME and signs the identity headers itself; the bytes the recipient's client decodes are the same either way.

Refused, before the request is made

Each of these raises a Posthaste::MappingError naming the field, rather than sending a message that is quietly missing something.

What Why
Sender, Return-Path, DKIM-Signature, Received, Authentication-Results, Feedback-ID, ARC-* The platform writes and DKIM-signs these. A second copy either duplicates a signed header — some receivers reject two Froms — or drifts from the signed value and fails DMARC. Feedback-ID in particular is the key Google aggregates complaint rates by.
The same custom header set twice The API carries one value per header name, and keeping one of the two would be a silent choice about which the recipient sees.
A from that parses to more than one address Almost always an unquoted comma: from: "Acme, Inc. <billing@acme.test>" is two addresses to any RFC 5322 parser. Quote it: from: %q("Acme, Inc." <billing@acme.test>).
A part that is neither body nor named attachment — a text/calendar invitation, an attachment with no filename Mail identifies an attachment by its filename, so these are not in mail.attachments at all and would vanish between here and the wire. Give it a filename, or use the SMTP relay, which accepts a complete MIME message.
A message with no to There is nobody to send it to.

Refusals are typed

A suppressed recipient, an unverified domain and an exhausted quota are three different problems with three different fixes. One generic error is what makes an integration miserable to debug.

begin
  InvoiceMailer.issued(invoice).deliver_now

rescue Posthaste::SuppressedError => e
  # Never work around this. Sending to a suppressed address is how a sending IP
  # gets blocklisted, and the block lands on everyone sharing it.
  stop_mailing(e.suppression.address, e.suppression.reason)   # 'complaint'

rescue Posthaste::DomainNotVerifiedError
  # Publish the DKIM record and verify the domain. There is no flag for this.
  alert_ops!

rescue Posthaste::QuotaExhausted => e
  # Hours or days, not seconds. Queue it or alert — do NOT sleep.
  Retry.tomorrow(invoice, after: e.retry_after_seconds)

rescue Posthaste::RateLimited => e
  # Transient, and it clears on its own.
  Retry.in(e.retry_after_seconds || 60, invoice)

rescue Posthaste::ContentBlockedError => e
  # The whole lint report, warnings included, so one fix pass covers everything.
  Rails.logger.error([e.check, e.findings].inspect)
end
Posthaste::Error
├── Posthaste::ConfigurationError      no key, or an unknown setting
├── Posthaste::MappingError            the message cannot be expressed
│   └── Posthaste::UnsupportedHeaderError
├── Posthaste::ConnectionError         never reached the server (status 0)
│   └── Posthaste::TimeoutError
└── Posthaste::APIStatusError
    ├── Posthaste::AuthenticationError    401
    ├── Posthaste::PermissionDeniedError  403 — a real key without emails:send
    ├── Posthaste::InvalidRequestError    400
    ├── Posthaste::NotFoundError          404
    ├── Posthaste::ConflictError          409
    ├── Posthaste::UnprocessableError     422 — permanent, never retried
    │   ├── Posthaste::SuppressedError
    │   ├── Posthaste::DomainNotVerifiedError
    │   ├── Posthaste::ContentBlockedError
    │   ├── Posthaste::AttachmentError
    │   ├── Posthaste::ScheduleError
    │   ├── Posthaste::TemplateError
    │   └── Posthaste::StreamError
    ├── Posthaste::RateLimited            429 — transient
    ├── Posthaste::QuotaExhausted         429 — a SIBLING, never a subclass
    └── Posthaste::ServerError            5xx

QuotaExhausted being a sibling of RateLimited rather than a subclass is deliberate. All four of the API's 429s look identical at the status level, and they are not: rate_limited and platform_paused clear in seconds, while daily_limit_reached and monthly_limit_reached clear when the calendar moves. A rescue Posthaste::RateLimited that slept would sleep for a week.

Branch on error.type — a stable machine-readable string — never on the message. An error.type this version has never seen is not a bug: the API is allowed to add refusal reasons, and an unknown one becomes the class its status implies rather than being mistaken for a documented one. Posthaste::KNOWN_ERROR_TYPES is the list this version knows.

raise_delivery_errors = false still swallows all of it, because this is a real ActionMailer delivery method and Mail::Message#do_delivery is what rescues.

Retries

A failed send is repeated automatically only when it carries an idempotency key, because without one a retry after a lost response sends the email twice. For a mail API that is not a performance detail; it is the difference between one invoice and two.

Set X-Posthaste-Idempotency-Key and you get two retries with exponential backoff and full jitter, honouring Retry-After up to max_retry_delay (60 seconds by default) and never retrying an exhausted quota. Beyond that ceiling the error comes back so you can schedule it rather than blocking a web request.

The key is a body field, not the Idempotency-Key HTTP header. The header is in the API's CORS allowlist but no handler reads it, so a client that sends the header and not the field gets no idempotency at all and no warning that it has none. This gem never sends the header.

The API key

The key is a bearer credential: whoever holds it can send from every verified domain on the account. It escapes through the places nobody guards — Ruby's default #inspect prints every instance variable, and Rails' exception page, a binding.irb pasted into a ticket, and an error reporter capturing locals all call it on your behalf.

So every object here defines its own #inspect, and every string built for a human goes through a redactor first:

ActionMailer::Base.delivery_method  # => :posthaste
mailer.message.delivery_method.inspect
# => #<Posthaste::DeliveryMethod api_key="ph_test_***redacted***" …>

The environment prefix is kept because it is not secret — it is printed beside every key in the dashboard — and it is the one piece that tells somebody staring at a 401 that they pasted the test key into production. It is deliberately not the usual last-four-characters convention: four characters of a token this size cannot authenticate, but they are enough to confirm a guess.

Server messages are redacted too, in two passes: this client's own key, and then anything key-shaped, which catches a different account's key echoed back by a proxy that quoted the request line.

Testing your own mailers

Nothing changes. delivery_method = :test, ActionMailer::Base.deliveries and assert_emails all keep working, because this is a delivery method registered through ActionMailer's own add_delivery_method rather than a replacement for any part of it.

To drive this gem in a test without a network, pass a transport: — anything that responds to call(method, url, headers, body) and returns a Posthaste::Response.

Running this gem's own tests

cd packages/actionmailer-ruby
ruby -Ilib -Itest -e 'Dir["test/**/*_test.rb"].each { |f| require "./#{f}" }'

The suite never makes a real network call and never sends real mail: Net::HTTP is poisoned in test_helper.rb, and every address in the package is under an RFC 2606 reserved domain, enforced by no_real_recipients_test.rb.

Licence

MIT.