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.
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.
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_eventDo 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.
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.
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.
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_laterdeliver_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 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.
<%# 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.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.
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.
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.
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.
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.
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
endA 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.
# 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 = trueThe 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.
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 = trueSMTP 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.
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
endThe 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.
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
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.
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.
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.
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
endProcess 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.
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
endCONFIGURATION
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 acceptanceAssert 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.