All dispatchesView as Markdown

How to send email in Ruby on Rails—Action Mailer, jobs, and reliable delivery

MC
Maya ChenEmail infrastructure at Email Bump

An in-depth Rails email guide covering Action Mailer, HTTP APIs and SMTP, multipart templates, Active Job and Solid Queue, transaction safety, idempotency, retries, webhooks, previews, and testing.

Rails makes the first email pleasantly small: generate a mailer, add an ERB view, and call deliver_later. That is the right starting abstraction. It is not the complete reliability model for a receipt, verification link, security alert, or invitation that must survive a rolled-back transaction, a failed enqueue, a duplicate job, a provider timeout, or a deployment halfway through delivery.

This guide keeps the parts Rails already does well—Action Mailer, layouts, previews, Active Job, Global ID, and testing helpers—then makes the failure boundaries explicit. The examples use current Rails conventions, Solid Queue, PostgreSQL, and Email Bump’s transactional API, with SMTP shown as an equally valid transport when a managed relay is the right contract.

Reliable Rails email architecture showing a controller or domain command, an Active Record transaction with an email delivery row, Solid Queue workers, Action Mailer rendering, HTTP or SMTP transport, and delivery webhooks
FIG.The Rails request, queue job, provider submission, and delivery event are separate milestones. A durable message row connects them without pretending the network participates in an Active Record transaction.

Choose the Rails boundary before choosing the provider

Most Rails applications should keep Action Mailer even when they submit over an HTTP API. Mailer classes, parameterized mailers, layouts, multipart views, previews, interceptors, observers, URL helpers, and test assertions remain useful regardless of transport. A custom delivery method lets the Mail::Message produced by Action Mailer flow through an API instead of SMTP.

Rails email architecture optionsruby
PATH                         GOOD FIT                              TRADEOFF
Action Mailer + HTTP method  Rails-native templates and API transport  write and maintain a thin adapter
Action Mailer + SMTP         managed relay or existing SMTP contract   reply mapping and provider metadata
Provider SDK service         provider-only feature outside email       separate preview/template path
Lifecycle event API          onboarding, win-back, campaigns           provider owns journey state
Local :test / :file          automated tests or safe development        never a production transport

Keep business commands above every option:
invite_member · send_receipt · resend_verification · notify_security_event

Do not expose a generic controller that accepts arbitrary from, to, subject, and html fields. The browser should request a named product action. Rails should authenticate and authorize the actor, load the authoritative recipient, select a fixed sender and template, enforce rate limits, and persist one semantic message intent.

An HTTP API often exposes structured errors, a provider message ID, metadata, and delivery events more directly. SMTP is not inherently less production-ready: a reputable relay can still own receiver delivery and reputation. The decision is about integration and operations, not whether one transport feels newer.

Fail configuration before accepting traffic

Rails credentials or a deployment secret store should hold production keys. Environment variables remain strings and can be absent, so validate required configuration during boot instead of discovering a missing sender after a customer completes checkout.

config/initializers/email.rbruby
EmailConfig = Data.define(
  :api_key,
  :from,
  :public_host,
  :public_protocol,
  :request_timeout
)

Rails.application.config.x.email = EmailConfig.new(
  api_key: Rails.application.credentials.dig(:email_bump, :api_key) ||
    ENV.fetch("EMAIL_BUMP_API_KEY"),
  from: ENV.fetch("EMAIL_FROM"),
  public_host: ENV.fetch("PUBLIC_APP_HOST"),
  public_protocol: ENV.fetch("PUBLIC_APP_PROTOCOL", "https"),
  request_timeout: Integer(ENV.fetch("EMAIL_REQUEST_TIMEOUT", "8"), 10)
)

config = Rails.application.config.x.email
raise "EMAIL_FROM is blank" if config.from.blank?
raise "PUBLIC_APP_PROTOCOL must be https" if Rails.env.production? && config.public_protocol != "https"
raise "EMAIL_REQUEST_TIMEOUT must be positive" unless config.request_timeout.positive?
  • Use distinct provider credentials and sending identities for development, staging, and production.
  • Do not commit master keys, populated .env files, SMTP passwords, webhook secrets, or generated reset links.
  • Set config.action_mailer.raise_delivery_errors to true where a worker must observe failure.
  • Keep perform_deliveries false or use the :test, :file, or a capture delivery method in safe environments.
  • Make queue, database, and provider readiness visible separately.
  • Rotate keys without requiring template code changes.

Send a first message through the Email Bump API

Before adding jobs and mailer abstractions, verify one restricted development key and sending identity with a small Rails runner script. Replace the example addresses with a verified sender and an inbox you control, then run bin/rails runner script/send_test_email.rb.

script/send_test_email.rbruby
require "json"
require "net/http"

uri = URI("https://emailbump.com/api/v1/emails")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("EMAIL_BUMP_API_KEY")}" 
request["Content-Type"] = "application/json"
request.body = JSON.generate(
  from: ENV.fetch("EMAIL_FROM"),
  to: "you@example.com",
  subject: "Hello from Rails",
  html: "<h1>Your Rails integration works</h1><p>This came from Email Bump.</p>",
  text: "Your Rails integration works\n\nThis came from Email Bump."
)

response = Net::HTTP.start(
  uri.host,
  uri.port,
  use_ssl: true,
  open_timeout: 5,
  read_timeout: 8,
  write_timeout: 8
) { |http| http.request(request) }

abort "Email API returned #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
puts JSON.pretty_generate(JSON.parse(response.body))

A successful response contains the provider’s message identity. Keep that ID in application state when the send matters. The rest of this guide turns this verified request into an Action Mailer delivery method with durable ownership, classified failures, and delivery-event processing.

Build one parameterized mailer per message family

A mailer action should accept stable identifiers or a narrow value object, load only what the template needs, and set instance variables for the view. Parameterized mailers keep call sites readable. Use a fixed default From identity, and choose a Reply-To only when the recipient has a useful monitored destination.

app/mailers/account_mailer.rbruby
class ApplicationMailer < ActionMailer::Base
  default from: -> { Rails.application.config.x.email.from }
  layout "mailer"

  self.deliver_later_queue_name = :mailers
end

class AccountMailer < ApplicationMailer
  def verification
    @delivery = EmailDelivery.find(params[:delivery_id])
    @user = User.find(@delivery.recipient_record_id)
    @verification_url = verify_email_url(
      token: @delivery.variables.fetch("token")
    )

    mail(
      to: @delivery.recipient,
      subject: "Verify your email address",
      reply_to: "support@example.com"
    )
  end
end

# Callers pass a durable delivery ID, not an arbitrary message body:
AccountMailer.with(delivery_id: delivery.id).verification.deliver_later

deliver_later serializes the mailer name, action, delivery method, arguments, and parameters for Active Job; the message is normally processed when the job runs. Passing an Active Record object uses Global ID, which means the worker resolves the record later. That is convenient, but it also means the record may be deleted or changed before rendering. Pass durable IDs and decide deliberately which values are event-time snapshots and which should be current at send time.

Snapshot versus recheck
SNAPSHOT WHEN INTENT IS CREATED       RECHECK IMMEDIATELY BEFORE SEND
receipt number and amount             whether a payment reminder is still needed
currency and paid-at timestamp        whether the account is still active
inviter and workspace display names   whether an invitation was revoked
message purpose and template version  whether the recipient is suppressed
stable action-token digest or ID       whether a one-time token remains usable

Never serialize secrets into job arguments when a durable record can hold a protected reference.

Render both HTML and plain text with absolute URLs

When matching .html.erb and .text.erb templates exist, Action Mailer can construct a multipart alternative message. Keep both versions semantically equivalent. ERB escapes ordinary output inserted with <%= %>; avoid raw or html_safe on untrusted values, and sanitize any deliberately accepted rich content with an explicit allowlist.

Mailer viewsruby
<%# app/views/account_mailer/verification.html.erb %>
<h1>Verify your email</h1>
<p>Hello <%= @user.display_name %>,</p>
<p><%= link_to "Verify email", @verification_url %></p>
<p>This link expires in 30 minutes.</p>

<%# app/views/account_mailer/verification.text.erb %>
Verify your email

Hello <%= @user.display_name %>,

Open this link to verify your email:
<%= @verification_url %>

This link expires in 30 minutes.

Mailers do not have an incoming request from which to infer a host. Configure default_url_options per environment and use *_url helpers, not relative *_path helpers. Generate security links from a trusted host and signed server-side token; never accept a return host or full action URL from an untrusted form.

config/environments/production.rbruby
config.action_mailer.default_url_options = {
  host: ENV.fetch("PUBLIC_APP_HOST"),
  protocol: "https"
}
config.action_mailer.asset_host = "https://#{ENV.fetch("ASSET_HOST")}"
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = true

# Use absolute URLs in mailer views:
# verify_email_url(token: token)
# not verify_email_path(token: token)

Understand what deliver_now and deliver_later actually promise

deliver_now processes and submits the message in the current thread. It makes the request slower and couples the controller to provider latency, but can be useful in a console command or a worker that already owns retries. deliver_later enqueues ActionMailer::MailDeliveryJob through Active Job; it does not mean delivered, and it is only durable if the configured queue adapter and enqueue path are durable.

Delivery milestones
CALL / STATE             WHAT IT PROVES
mailer.action              a lazy MessageDelivery object exists
deliver_later returns      the adapter accepted an enqueue request
delivery job starts        a worker acquired the queued job
deliver_now returns        the configured transport accepted submission
provider message ID saved  provider acceptance can be correlated
webhook: delivered         receiving server accepted the message
human opened or clicked    privacy-sensitive engagement signal, not delivery

Return 202 Accepted after durable ownership—not “email delivered.”

Do not use deliver_later! casually. The bang variant eventually calls deliver_now!, bypassing the normal perform_deliveries and raise_delivery_errors checks. The regular method is the safer default because staging and test controls remain effective.

Do not hide enqueue gaps inside model callbacks

after_create_commit is safer than after_create for work that must observe a committed record, but it is not atomic delivery. The database is already committed when the callback runs. If enqueueing raises, the user still exists and the email job may not. If a later after_commit callback raises, remaining callbacks may not run. Callback ordering and implicit side effects also make business workflows difficult to audit.

Prefer an explicit application service
class RegisterAccount
  Result = Data.define(:user, :email_delivery)

  def self.call(attributes:)
    User.transaction do
      user = User.create!(attributes)
      delivery = EmailDelivery.create!(
        purpose: "account-verification",
        dedupe_key: "account-verification:#{user.id}:v1",
        recipient: user.email,
        recipient_record_type: "User",
        recipient_record_id: user.id,
        template_key: "account-verification",
        template_version: "1",
        variables: { "token" => user.issue_verification_token! },
        state: "pending"
      )
      Result.new(user:, email_delivery: delivery)
    end
  end
end

# A dispatcher discovers the committed pending row.
# The controller never needs to make database commit depend on an email provider.

Rails can defer job enqueueing until after transaction commit, and a queue stored in the same ACID database can provide useful transactional behavior. Current Solid Queue guidance also warns that relying on this can break when the adapter changes or the queue moves to a separate database; Solid Queue is configured separately from the main app database by default. For critical messages, an explicit outbox row in the application transaction makes ownership portable and inspectable.

Use a delivery ledger as the Rails outbox

An EmailDelivery model can serve as an outbox and a long-lived evidence record. Put a unique constraint on semantic intent. Store only the minimum template variables needed for retry, encrypt sensitive values when appropriate, and keep product state separate from communication state.

db/migrate/create_email_deliveries.rbruby
create_table :email_deliveries, id: :uuid do |t|
  t.string :purpose, null: false
  t.string :dedupe_key, null: false
  t.string :recipient, null: false
  t.string :recipient_record_type
  t.uuid :recipient_record_id
  t.string :template_key, null: false
  t.string :template_version, null: false
  t.jsonb :variables, null: false, default: {}
  t.string :state, null: false, default: "pending"
  t.integer :attempts, null: false, default: 0
  t.datetime :next_attempt_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
  t.string :lease_owner
  t.datetime :lease_expires_at
  t.string :provider_message_id
  t.string :last_error_class
  t.datetime :accepted_at
  t.datetime :delivered_at
  t.timestamps
end

add_index :email_deliveries, :dedupe_key, unique: true
add_index :email_deliveries,
  [:state, :next_attempt_at],
  name: "index_email_deliveries_claimable"

If a broker is separate from the application database, a dispatcher can poll pending delivery rows and enqueue their IDs. It must tolerate enqueueing the same ID more than once: update an enqueued marker conditionally, keep the job idempotent, and let the delivery row remain authoritative. Alternatively, a worker can lease rows directly from PostgreSQL with FOR UPDATE SKIP LOCKED.

Lease one delivery with Active Record
def claim_delivery(worker_id:)
  EmailDelivery.transaction do
    delivery = EmailDelivery
      .where(state: %w[pending retry_wait])
      .where("next_attempt_at <= ?", Time.current)
      .or(
        EmailDelivery.where(state: "leased")
          .where("lease_expires_at < ?", Time.current)
      )
      .order(:next_attempt_at, :created_at)
      .lock("FOR UPDATE SKIP LOCKED")
      .first

    return unless delivery

    delivery.update!(
      state: "leased",
      lease_owner: worker_id,
      lease_expires_at: 60.seconds.from_now,
      attempts: delivery.attempts + 1
    )
    delivery
  end
end

# Commit the claim before rendering or crossing the network.

Finish the row with a conditional update that still requires the same lease owner. If a worker pauses beyond lease expiry, another worker may reclaim it. A lease prevents ordinary concurrent execution; it cannot prevent duplication when the provider accepts a message and the response is lost.

Connect Action Mailer to an HTTP API safely

A custom Action Mailer delivery method implements initialize(settings) and deliver!(mail). The adapter should extract both MIME alternatives, apply explicit network timeouts, parse the provider message ID, and raise typed exceptions that the job layer can classify. Keep secrets and full message bodies out of logs.

app/lib/email_bump_delivery.rbruby
require "json"
require "net/http"

class EmailBumpDelivery
  class TransientError < StandardError; end
  class PermanentError < StandardError; end
  class AmbiguousError < StandardError; end

  def initialize(settings)
    @api_key = settings.fetch(:api_key)
    @timeout = settings.fetch(:timeout, 8)
  end

  def deliver!(mail)
    uri = URI("https://emailbump.com/api/v1/emails")
    request = Net::HTTP::Post.new(uri)
    request["Authorization"] = "Bearer #{@api_key}"
    request["Content-Type"] = "application/json"
    request.body = JSON.generate(
      from: mail[:from].to_s,
      to: mail.to&.first,
      reply_to: mail.reply_to&.first,
      subject: mail.subject,
      html: mail.html_part&.decoded,
      text: mail.text_part&.decoded || mail.body.decoded
    )

    response = Net::HTTP.start(
      uri.host,
      uri.port,
      use_ssl: true,
      open_timeout: @timeout,
      read_timeout: @timeout,
      write_timeout: @timeout
    ) { |http| http.request(request) }

    provider_id = case response.code.to_i
    when 200..299 then JSON.parse(response.body).fetch("id")
    when 408, 429, 500..599 then raise TransientError, "Temporary email provider response"
    else raise PermanentError, "Email provider rejected the request (#{response.code})"
    end

    # Local adapter-to-job metadata; it is not included in the API payload.
    mail.header["X-Email-Bump-Message-ID"] = provider_id
    mail
  rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
         EOFError, Errno::ECONNRESET => error
    raise AmbiguousError, "Email submission ended without a response", cause: error
  end
end

A timeout is not proof that nothing happened. The remote service may have accepted the request just before the response was lost. Reusing the outbox ID as a provider idempotency key, where supported, is the best defense. Otherwise store an ambiguous state and reconcile provider activity before resending a high-impact message.

Register the custom delivery method
# config/initializers/email_delivery_method.rb
ActionMailer::Base.add_delivery_method(
  :email_bump,
  EmailBumpDelivery,
  api_key: Rails.application.config.x.email.api_key,
  timeout: Rails.application.config.x.email.request_timeout
)

# config/environments/production.rb
config.action_mailer.delivery_method = :email_bump
config.action_mailer.perform_deliveries = true
config.action_mailer.raise_delivery_errors = true

The standard-library example opens a connection per send, which is simple and safe at modest volume. At higher throughput, use a measured, thread-safe pool or supported HTTP client with explicit connection and request limits. Do not share an unsafe mutable connection across Puma threads or fork it into worker processes.

Configure SMTP with the same rigor

Action Mailer’s SMTP method is a strong fit for a managed relay. Configure TLS verification, authentication, HELO domain, and bounded connection/read timeouts. Do not disable certificate verification to make a rollout pass, and do not use a personal mailbox credential as production infrastructure.

SMTP production configuration
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: ENV.fetch("SMTP_HOST"),
  port: Integer(ENV.fetch("SMTP_PORT", "587"), 10),
  domain: ENV.fetch("SMTP_HELO_DOMAIN"),
  user_name: Rails.application.credentials.dig(:smtp, :username),
  password: Rails.application.credentials.dig(:smtp, :password),
  authentication: :plain,
  enable_starttls: true,
  openssl_verify_mode: "peer",
  open_timeout: 5,
  read_timeout: 8
}
config.action_mailer.raise_delivery_errors = true

SMTP 4xx replies are generally temporary and 5xx replies generally permanent, but map the actual Mail gem exception and relay policy. Per-recipient refusal, authentication failure, and an unknown post-DATA connection loss need different states. SMTP acceptance still is not mailbox delivery; ingest relay webhooks or logs to learn about later bounces and complaints.

Give mail jobs explicit retry and concurrency policy

Active Job supports retry_on and discard_on, but retrying every StandardError is dangerous. A malformed template, missing credential, invalid sender, or authorization bug will not heal with exponential delay. Retry classified temporary failures, preserve ambiguous results, and send permanent failures to a visible repair or dead-letter workflow.

app/jobs/send_email_delivery_job.rbruby
class SendEmailDeliveryJob < ApplicationJob
  queue_as :mailers

  retry_on EmailBumpDelivery::TransientError,
    wait: :polynomially_longer,
    attempts: 8,
    jitter: 0.20

  discard_on ActiveJob::DeserializationError

  # Solid Queue extension; tune key and duration to your policy.
  limits_concurrency to: 4,
    key: ->(delivery_id) { EmailDelivery.find(delivery_id).purpose },
    duration: 2.minutes

  def perform(delivery_id)
    delivery = EmailDelivery.find(delivery_id)
    return if delivery.state.in?(%w[accepted delivered suppressed])

    suppress!(delivery) and return unless eligible?(delivery)

    message = AccountMailer.with(delivery_id: delivery.id).verification
    message.deliver_now
    provider_id = message.message["X-Email-Bump-Message-ID"]&.value
    raise EmailBumpDelivery::PermanentError, "Missing provider ID" if provider_id.blank?

    mark_accepted!(delivery, provider_id)
  rescue EmailBumpDelivery::AmbiguousError
    delivery.update!(state: "ambiguous", last_error_class: "ambiguous")
  rescue EmailBumpDelivery::PermanentError => error
    delivery.update!(state: "failed", last_error_class: error.class.name)
  end
end

The example makes the local X-Email-Bump-Message-ID header an explicit adapter-to-job contract; the API payload does not transmit that internal header. A dedicated provider service can instead return a typed result while Action Mailer remains the renderer. Whichever shape you choose, test the boundary and store the provider ID before declaring acceptance.

Solid Queue can scale with threads, processes, or horizontal workers and supports concurrency controls. Separate queues by urgency: security and password email should not sit behind a million low-priority summaries. Exact queue lists can also avoid expensive wildcard polling. Bound concurrency by provider rate limits, database pool size, HTTP or SMTP capacity, memory, and how much work can drain during shutdown.

config/queue.ymlyaml
production:
  dispatchers:
    - polling_interval: 1
      batch_size: 200
  workers:
    - queues: [mailers_critical]
      threads: 3
      processes: 1
      polling_interval: 0.1
    - queues: [mailers, default]
      threads: 5
      processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
      polling_interval: 0.2

# Keep Active Record pool size above the maximum concurrent DB users
# in each process, with headroom for framework and instrumentation work.

Make idempotency a database rule

Three idempotency layers
SOURCE EVENT
stripe_event_id, webhook_event_id, or command ID
Prevents interpreting one external event twice.

MESSAGE INTENT
payment-receipt:{payment_id}:v4
Prevents two code paths creating the same customer communication.

PROVIDER REQUEST
email_delivery.id where the provider supports idempotency
Prevents duplicate remote acceptance after response loss.

JOB EXECUTION
email_delivery state + conditional updates
Makes duplicate queue jobs harmless after acceptance.

Use insert_all with unique_by, create_or_find_by!, or another database-backed creation path that respects the unique index under concurrency. A prior application-level exists? check is not enough. Do not deduplicate forever by recipient or subject, because both block legitimate future messages.

For reminders and lifecycle messages, eligibility is separate from deduplication. A queued payment reminder should suppress itself if the invoice is paid before the worker runs. A verification email should avoid issuing an extra token on every retry. Store a durable intent, then recheck current state immediately before provider submission.

Protect staging with previews and interceptors

Action Mailer previews provide a fast review loop without sending. Create representative fixtures for short and long names, empty optional data, Unicode, narrow layouts, dark mode, and expired-state copy. A preview is also a useful contract between engineering, product, support, and compliance.

test/mailers/previews/account_mailer_preview.rbruby
class AccountMailerPreview < ActionMailer::Preview
  def verification
    delivery = EmailDelivery.find_by!(
      dedupe_key: "preview:account-verification"
    )

    AccountMailer.with(delivery_id: delivery.id).verification
  end
end

# Create this deterministic record in development preview seeds.
# Never use production records or live action tokens in a preview.

Interceptors can rewrite recipients before a message reaches the delivery method. Register a staging-only interceptor that sends everything to a controlled sink and prefixes the subject with the intended recipient. Do not rely only on developer discipline or a banner; enforce the boundary in configuration and provider credentials.

Staging recipient interceptor
class StagingEmailInterceptor
  def self.delivering_email(message)
    intended = Array(message.to).join(", ")
    message.header["X-Intended-Recipient"] = intended
    message.subject = "[STAGING → #{intended}] #{message.subject}"
    message.to = [ENV.fetch("STAGING_EMAIL_SINK")]
    message.cc = nil
    message.bcc = nil
  end
end

# config/environments/staging.rb
config.action_mailer.interceptors = ["StagingEmailInterceptor"]

Process delivery webhooks as a durable inbox

Provider submission proves acceptance, not inbox placement or human attention. Delivery, deferral, bounce, complaint, and suppression events arrive later. Verify the signature against the exact raw request body required by the provider, apply a strict size and timestamp policy, insert the provider event ID uniquely, and acknowledge only after commit.

Webhook boundaryruby
class EmailEventsController < ActionController::API
  MAX_BYTES = 256.kilobytes

  def create
    raw_body = request.raw_post
    return head :payload_too_large if raw_body.bytesize > MAX_BYTES

    EmailWebhookVerifier.verify!(
      payload: raw_body,
      signature: request.headers["Email-Webhook-Signature"],
      timestamp: request.headers["Email-Webhook-Timestamp"]
    )
    event = JSON.parse(raw_body)

    ProviderEvent.create_or_find_by!(provider_event_id: event.fetch("id")) do |row|
      row.event_type = event.fetch("type")
      row.payload = event
    end

    head :ok
  rescue EmailWebhookVerifier::InvalidSignature, JSON::ParserError
    head :bad_request
  end
end

Process stored events asynchronously and make state transitions provider-specific and order-aware. An older deferred event should not erase terminal delivered evidence. A bounced receipt does not roll back a payment, and a complaint does not delete the originating account event; product state and communication state remain separate.

Observe Rails, queue, provider, and mailbox milestones separately

  • Log delivery ID, purpose, template version, job ID, attempt, provider ID, latency, and outcome.
  • Do not log credentials, complete message bodies, action tokens, or unnecessary recipient addresses.
  • Track enqueue failures, queue wait time, oldest ready delivery, retry age, and dead letters.
  • Measure rendering, provider request, provider acceptance, and delivery-event latency separately.
  • Alert on authentication failures, ambiguous submissions, permanent failures, bounce spikes, and complaints.
  • Attach Rails release, template version, queue adapter, and provider transport to incident evidence.

Active Support notifications and queue instrumentation can connect controller, Active Job, and Action Mailer timing, but in-memory context disappears between processes. Persist the email delivery ID and trace identifier in durable state. Use tagged structured logs rather than trying to reconstruct one message from an address and subject.

Shut down workers without pretending cleanup is guaranteed

Solid Queue’s supervisor responds to TERM and INT with graceful termination, then uses its configured shutdown timeout before forcing remaining processes. Give the platform deadline enough room for the worker timeout, and stop taking new work before draining active jobs. A provider request must never have an unbounded socket timeout.

Graceful shutdown improves normal deployments, but SIGKILL, host loss, and runtime crashes bypass ensure blocks. Solid Queue records unexpected worker loss, while an outbox lease or idempotent job makes the email recoverable. Do not mark a delivery accepted before the provider responds and the provider ID is durably stored; preserve ambiguity when those two events cannot be distinguished.

Test rendering, enqueueing, transport, and state transitions

ActionMailer::TestCase can inspect rendered headers and parts without a real send. Active Job test helpers can assert the mailer and parameters that were enqueued. Keep provider adapter tests at the HTTP boundary, then add a small sandbox or sink integration suite for the real network contract.

Mailer and enqueue testsruby
class AccountMailerTest < ActionMailer::TestCase
  test "verification renders both alternatives" do
    delivery = email_deliveries(:verification)
    email = AccountMailer.with(delivery_id: delivery.id).verification

    assert_equal [delivery.recipient], email.to
    assert_equal "Verify your email address", email.subject
    assert_includes email.text_part.body.decoded, "https://"
    assert_includes email.html_part.body.decoded, "Verify email"
    refute_includes email.html_part.body.decoded, "<script"
  end
end

class RegisterAccountTest < ActiveJob::TestCase
  test "commits one semantic email intent" do
    result = RegisterAccount.call(attributes: valid_attributes)

    assert_equal "pending", result.email_delivery.state
    assert_equal 1, EmailDelivery.where(
      dedupe_key: "account-verification:#{result.user.id}:v1"
    ).count
  end
end
Reliability test matrix
CONFIGURATION
missing credential · invalid host · blank sender · unsafe staging key

MAILER
HTML escaping · text alternative · absolute URL · Unicode · attachment size

TRANSACTION
rollback · concurrent duplicate intent · enqueue failure after commit

JOB
already accepted · stale suppression · deserialization error · worker loss

PROVIDER
2xx + message ID · 400 permanent · 429 retry · 500 retry
connect/read timeout · accepted before response loss · malformed JSON

WEBHOOK
invalid signature · duplicate ID · reordered events · unknown provider ID

DEPLOYMENT
TERM while idle · while rendering · during submission · after acceptance

Assert durable state, not only that a method was called. A transaction rollback should leave no user and no email intent. A duplicate command should leave one delivery row. A 429 should schedule bounded retry. A response-less timeout should become ambiguous. A duplicate webhook should create one provider-event record.

Production checklist

  • Keep Action Mailer as the rendering layer unless a different boundary has a clear benefit.
  • Validate credentials, sender, URLs, and timeouts before the process reports ready.
  • Expose named, authorized product commands instead of a generic email relay.
  • Use parameterized mailers with durable IDs and deliberate snapshot/current values.
  • Render escaped HTML and equivalent plain text with absolute HTTPS URLs.
  • Treat deliver_later as queue acceptance—not provider or mailbox delivery.
  • Avoid relying on implicit callback and cross-database enqueue atomicity.
  • Commit an outbox or delivery-ledger row with the business transaction.
  • Enforce semantic idempotency with a database unique constraint.
  • Use short claims, leases, conditional updates, and crash recovery.
  • Centralize HTTP or SMTP timeouts, response parsing, and error classes.
  • Retry only temporary failures with jitter, limits, and stale-state checks.
  • Preserve ambiguous submissions and reconcile before risky resends.
  • Separate critical mail queues and bound concurrency to real capacity.
  • Protect staging with restricted keys, capture sinks, and interceptors.
  • Verify and deduplicate webhooks before updating delivery evidence.
  • Drain normal deployments while remaining safe under hard process loss.
  • Test transactions, jobs, provider failures, duplicates, and event ordering.