PosthasteDocsGet an API key

Sending

Rails and ActionMailer

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

The whole change

# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: 'smtp.sendgrid.net',
  port: 587,
  user_name: 'apikey',
  password: ENV['SENDGRID_KEY'],
}

# …and every mailer in app/mailers, elsewhere.

posthaste-rails registers a delivery method through ActionMailer’s own add_delivery_method. Its only dependency is ActionMailer itself — the HTTP is Net::HTTP and the JSON is the standard library — so it adds nothing to your lockfile that Rails was not already carrying. Ruby 3.1 or newer, ActionMailer 6.1 or newer.

The from address must be on one of your verified domains, exactly as it must for a direct API send. Nothing else about your mailers changes:

class InvoiceMailer < ApplicationMailer
  def issued(invoice)
    @invoice = invoice
    attachments['invoice.pdf'] = invoice.to_pdf
    mail(
      to: invoice.customer.email,
      cc: invoice.accountant_email,
      reply_to: 'Acme Billing <[email protected]>',
      subject: "Invoice #{invoice.number}",
    )
  end
end

InvoiceMailer.issued(invoice).deliver_now

When to use this instead of the SMTP relay

Posthaste already runs an SMTP relay, and ActionMailer can point at it today with no gem at all. That is a perfectly good answer, and for a long-running server on a network that allows outbound SMTP it is often the simpler one — so here is the honest comparison rather than a pitch.

Reach forWhen
SMTP relayYou are on an ordinary server or container, port 587 is open outbound, and you send ordinary mail. Nothing to install, and it carries anything ActionMailer can compose — including calendar invitations, which this gem cannot.
This gemOutbound SMTP is blocked or throttled where you run. Most serverless and container platforms either block port 25/587 outright or make a long-lived connection expensive. An HTTPS request has neither problem.
This gemYou want the fields SMTP has nowhere to put: tags and metadata, message streams, stored templates, an explicit idempotency key, or scheduled sending.
This gemYou want to branch on why a message was refused. SMTP gives you a three-digit code and a sentence; this gives you an exception class, a stable type, retry_after_seconds, and the evidence behind the refusal.

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  # [{ 'to' => …, 'reason' => 'complaint' }]
mail.posthaste_result.warnings    # the platform's pre-send lint findings
mail.posthaste_result.mapping_warnings # what the gem had to change

#posthaste_result is the only method the gem adds to a class it does not own, and it returns nil for a message delivered any other way. The duplicate? flag is read from the response body rather than from a 200 versus a 202, because the body is the authoritative answer and survives a proxy that normalises the status — and an application that bills or counts per send has to be able to tell an idempotency replay from a new message.

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.customer.email,
    subject: "Invoice #{invoice.number}",

    # Everything the API offers that ActionMailer has no word for.
    '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}",
    'X-Posthaste-Scheduled-At' => '2026-09-01T09:00:00Z',
  )
end
HeaderBecomesNotes
X-Posthaste-StreamstreamOverrides the settings-level default. See message streams.
X-Posthaste-TagstagsComma-separated.
X-Posthaste-MetadatametadataA JSON object. Values are coerced to strings.
X-Posthaste-Idempotency-KeyidempotencyKeyThe one thing that makes a send retryable — see idempotency.
X-Posthaste-Template, -Template-Version, -Variablestemplate, templateVersion, variablesThe stored template supplies the body.
X-Posthaste-Scheduled-AtscheduledAtRFC 3339, e.g. 2026-09-01T09:00:00Z.
mail(
  to: invoice.customer.email,
  subject: "Invoice #{invoice.number}",
  # No view is sent: the stored template supplies both parts.
  'X-Posthaste-Template' => 'invoice-issued',
  'X-Posthaste-Variables' => { name: 'Ada', amount: '£42.00' }.to_json,
)

These headers 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 silently ignores the stream it was told to use.

What maps, what changes, what is refused

ActionMailer hands a delivery method a fully-composed Mail::Message, which is 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. So anything that cannot be honoured is either a warning you can see or a synchronous error naming the field, never a quiet omission.

Mapped, unchanged

ActionMailerBecomesNotes
fromfromDisplay name kept.
to, cc, bccto, cc, bccEach recipient is a separate message and counts as one send. See the display-name caveat below.
reply_toreplyToDisplay names kept, and several addresses kept.
text and HTML templatestext, htmlFound wherever the message put them — including the multipart/mixed[ multipart/alternative[…], attachment ] shape Rails builds for two templates plus a file. A single-template mailer is not multipart at all, and its own Content-Type decides which field it is.
attachments[…]attachmentsBase64-encoded, with the declared content type.
attachments.inline[…]attachmentsdisposition: inline with the cid kept, so <img src="cid:…"> still resolves.
List-UnsubscribelistUnsubscribeReserved as a header, first-class as a field.
any other headerheadersOne value per name.

Mapped, with something changed

Each of these produces a mapping warning: it appears on mail.posthaste_result.mapping_warnings and is passed to the on_warning hook, so you find out on the first send rather than in a support ticket.

WhatWhat happensWhy
Display names on to / cc / bccThe 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 instead would break the migration for nearly every application that has ever set a recipient name.
A body whose declared charset does not decodeUnreadable bytes are replaced.The message still goes and the damage is confined to bytes that were already unreadable.
X-Posthaste-Template alongside a rendered viewThe template supplies the content; the view is not sent.Sending both would make the API choose, and whichever it chose 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. Posthaste composes the MIME and signs the identity headers itself, and 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.

WhatWhy
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, so a customer who could set it could file their complaints under somebody else’s identifier.
The same custom header set twiceThe API carries one value per header name, and keeping one of the two would be a silent choice about which one the recipient sees.
A from that parses to more than one addressAlmost always an unquoted comma: from: 'Acme, Inc. <[email protected]>' is two addresses to any RFC 5322 parser. Quote the display name and it is one again.
A part that is neither body nor named attachment — a text/calendar invitation, or an attachment with no filenameMail identifies an attachment by its filename, so neither is in mail.attachments at all and both would vanish between the mapper and the wire. Give it a filename, or use the SMTP relay, which accepts a complete MIME message.

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, so each of them is its own class and each of them arrives out of deliver_now.

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.
  InvoiceMailJob.set(wait: e.retry_after_seconds).perform_later(invoice)

rescue Posthaste::RateLimited => e
  # Transient, and it clears on its own.
  InvoiceMailJob.set(wait: e.retry_after_seconds || 60).perform_later(invoice)

rescue Posthaste::ContentBlockedError => e
  # The whole lint report, warnings included, so one pass fixes everything.
  Rails.logger.error([e.check, e.findings].inspect)
end

Every class descends from Posthaste::Error, so one rescue catches the lot. Posthaste::UnprocessableError covers the 422s, which are permanent by construction — the same bytes get the same answer — and nothing under it is ever retried.

QuotaExhausted is a sibling of RateLimited, never a subclass. 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, and never on the message. A type this gem 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.

config.action_mailer.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 on a job rather than blocking a web request for a quarter of an hour.

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

Settings

Everything is optional except the key, which falls back to POSTHASTE_API_KEY. base_url is the whole story for a self-hosted install: point it at your own API host and the rest works unchanged.

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}") },
}

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

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.

mailer.message.delivery_method.inspect
# => #<Posthaste::DeliveryMethod api_key="ph_live_***redacted***" …>

So every object in the gem defines its own #inspect, and every string built for a human goes through a redactor first. The ph_live_ prefix survives because it is not secret — it is printed beside every key in the dashboard — and it is enough to confirm that what was passed is a Posthaste key at all. 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 in two passes — this client’s own key, then anything key-shaped — which catches a different account’s key echoed back by a proxy that quoted the request line.

Your own mailer tests

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. Leave your test environment on :test and your development environment on :letter_opener if that is what it is on today.

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

NextSend an emailEvery field on POST /v1/emails, including replyTo and List-Unsubscribe.