class EmailDedupService
DEFAULT_TTL = 5.minutes.to_i
def self.claim(fingerprint, ttl: DEFAULT_TTL)
key = "email_dedup:#{fingerprint}"
# Atomic set-if-absent with expiry; returns true only for the first caller.
redis.set(key, Time.now.to_i, nx: true, ex: ttl) ? true : false
rescue Redis::BaseError => e
Rails.logger.warn("[EmailDedup] redis unavailable, failing open: #{e.message}")
true
end
def self.fingerprint(message)
parts = [
Array(message.to).sort.join(","),
Array(message.cc).sort.join(","),
message.subject.to_s,
message.body.to_s
]
Digest::SHA256.hexdigest(parts.join("\u0000"))
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
end
end
class ThrottleDuplicatesInterceptor
def self.delivering_email(message)
return if opted_out?(message)
fingerprint = EmailDedupService.fingerprint(message)
unless EmailDedupService.claim(fingerprint, ttl: ttl_for(message))
Rails.logger.info(
"[EmailDedup] suppressed duplicate to=#{Array(message.to).join(',')} " \
"subject=#{message.subject.inspect} fp=#{fingerprint[0, 12]}"
)
message.perform_deliveries = false
end
end
def self.opted_out?(message)
message.header["X-No-Dedup"].present?
end
def self.ttl_for(message)
override = message.header["X-Dedup-TTL"]&.value
override.present? ? override.to_i : EmailDedupService::DEFAULT_TTL
end
end
require Rails.root.join("app/services/email_dedup_service")
require Rails.root.join("app/mailers/interceptors/throttle_duplicates_interceptor")
Rails.application.config.to_prepare do
ActionMailer::Base.register_interceptor(ThrottleDuplicatesInterceptor)
end
This snippet shows how to stop Rails from sending the same email twice within a short window by combining a mailer interceptor with a small Redis-backed dedup service. The pattern solves a common production problem: retried background jobs, double-clicked buttons, and webhook storms can all trigger identical emails seconds apart, and recipients notice. Rather than sprinkling guards through every mailer, the logic is centralized in one place that ActionMailer already invokes for every outgoing message.
In EmailDedupService, the core idea is a fingerprint plus an atomic claim. fingerprint hashes the recipients, subject, and body so that two structurally identical messages collapse to the same key, while different content stays distinct. The claim method uses Redis SET with nx: true and ex: ttl — a single round-trip that atomically sets the key only if it is absent and attaches an expiry. When Redis returns truthy the caller won the race and may send; otherwise the message is a duplicate inside the window. This avoids the classic check-then-act race where two workers both read "not seen" and both send.
ThrottleDuplicatesInterceptor implements the delivering_email(message) hook that ActionMailer calls just before delivery. It builds the fingerprint from the Mail::Message, asks EmailDedupService.claim for it, and calls message.perform_deliveries = false to cancel the send when a duplicate is detected. Setting that flag is the idiomatic ActionMailer way to suppress a message without raising, so callbacks and logging still run cleanly. Transactional mail with X-No-Dedup set can opt out entirely.
config/initializers/email_dedup.rb registers the interceptor via ActionMailer::Base.register_interceptor, wiring it into every mailer globally with no per-mailer changes. The trade-offs are worth understanding: dedup is best-effort and depends on Redis availability, so the service fails open to avoid dropping legitimate mail; the TTL defines the throttle window and must balance duplicate suppression against blocking intentional resends; and the fingerprint deliberately ignores headers like Date that differ between otherwise-identical messages. This approach fits any app where at-least-once job delivery meets user-facing email.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.