Most newsletter setups start with "sign up for Mailchimp." This one starts with rails g model. If you already run a Rails app, everything you need for a proper double opt-in newsletter is already in the box. Here's how ours works.
The subscriber lifecycle
A subscriber goes through three states: pending, confirmed, and unsubscribed. One table tracks all three with two timestamp columns:
create_table :subscribers do |t|
t.string :email, null: false
t.datetime :confirmed_at
t.datetime :unsubscribed_at
t.string :source
t.timestamps
end
add_index :subscribers, "lower(email)", unique: true
No status enum, no state machine gem. A nil confirmed_at means pending. A present confirmed_at with a nil unsubscribed_at means active. Both present means they left. The scopes write themselves:
scope :pending, -> { where(confirmed_at: nil, unsubscribed_at: nil) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :active, -> { confirmed.where(unsubscribed_at: nil) }
The functional lower(email) index catches duplicates regardless of casing. Combined with create_or_find_by!, re-subscribing the same address is a no-op instead of an error.
Double opt-in with signed IDs
The confirmation flow uses Rails' built-in signed IDs — no token column, no custom crypto:
# Generating the confirmation link
confirm_newsletter_url(
token: subscriber.signed_id(purpose: :newsletter_confirm, expires_in: 7.days)
)
# Verifying it
subscriber = Subscriber.find_signed!(params[:token], purpose: :newsletter_confirm)
subscriber.mark_confirmed!
signed_id encodes the record's ID with a purpose and expiry into a tamper-proof token. Rails handles the signing, verification, and expiration. The token lives in the URL, so there's nothing to store.
The same pattern works for unsubscribe links, just with a different purpose:
Subscriber.find_signed!(params[:token], purpose: :newsletter_unsubscribe)
One mechanism, two use cases.
Sending newsletters
Newsletters are campaigns. Each one is a database row with a subject, an HTML body, and a plain-text body. A rake task ties everything together:
bin/rails newsletter:send SUBJECT="Monthly note" MARKDOWN_FILE="path/to/issue.md"
The task reads a Markdown file, converts it to HTML with Kramdown, strips tags for the text version, and enqueues a delivery job for every active subscriber:
campaign = NewsletterCampaign.create_from_markdown_file!(subject:, file_path:)
Subscriber.active.find_each do |subscriber|
NewsletterMailer.issue(subscriber, campaign).deliver_later
end
Each email gets its own unsubscribe link. The List-Unsubscribe and List-Unsubscribe-Post headers tell email clients to show an unsubscribe button natively — Gmail, Apple Mail, and others will render it without you building any UI on their end.
Why not a third-party service?
For a personal blog with a modest list, the answer is simplicity and cost. The goal is to minimize the budget for this blog system as much as possible. No API keys to rotate, no webhook endpoints to maintain, and no monthly bill that scales with your subscriber count. The entire subscription system is about 150 lines of application code. Rails' deliver_later handles background delivery, and Amazon SES (Simple Email Service) handles the actual sending for pennies.
If the list grows to thousands and you need analytics, A/B testing, or deliverability optimization — sure, migrate to a dedicated service then. But starting with your own gives you something no SaaS does: complete understanding of every line in the stack, and a monthly bill that rounds to zero.