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..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.