ruby yaml 73 lines · 4 tabs

Weekly Digest Emails in Rails with a Cron Job and Query Scopes

Shared by codesnips Aug 2026
4 tabs
class User < ApplicationRecord
  has_many :posts
  has_many :comments

  scope :digest_subscribers, -> {
    where(digest_opt_in: true).where.not(confirmed_at: nil)
  }

  scope :with_unread_since, ->(since) {
    where(
      "EXISTS (SELECT 1 FROM notifications n " \
      "WHERE n.user_id = users.id AND n.read_at IS NULL AND n.created_at >= ?)",
      since
    )
  }

  def recent_activity(since)
    notifications
      .where(read_at: nil)
      .where("created_at >= ?", since)
      .order(created_at: :desc)
      .limit(25)
  end
end
4 files · ruby, yaml Explain with highlit

This snippet shows how a recurring weekly digest email is assembled in a Rails app using a scheduled background job, a set of composable ActiveRecord scopes, and a mailer. The unit of work is deliberately split so each piece stays testable in isolation: the scope decides who gets a digest and what they see, the job decides when and how many at a time, and the mailer decides how it looks.

In User digest scopes, the model defines two collaborating scopes. digest_subscribers narrows the population to people who opted in and confirmed their address, avoiding sending to bounced or unverified accounts. with_unread_since uses a correlated EXISTS subquery so users with no recent activity are excluded entirely at the database level rather than being loaded and filtered in Ruby — that keeps the working set small even for large tables. The instance method recent_activity reuses the same window to fetch the actual rows the mailer renders, so the presence check and the render query agree on the same cutoff.

In WeeklyDigestJob, the job is idempotent and batch-friendly. It computes since from a fixed window and iterates with find_each so memory stays flat regardless of subscriber count. The digest_sent_at guard means a re-run (from a retry or an overlapping schedule) will not double-send within the same window; this is the key safety property when a cron trigger and Sidekiq retries can both fire. Delivery uses deliver_later so the SMTP call happens in its own job and one slow send cannot stall the whole batch. Failures per user are rescued and logged so a single bad record doesn't abort the run.

In sidekiq-cron schedule, the recurring trigger is declared as data, not code: a cron expression enqueues WeeklyDigestJob every Monday morning in a fixed timezone. Keeping the schedule in a YAML file loaded at boot means it lives in version control and is visible in the Sidekiq web UI. A subtle trade-off worth noting: the job's own window calculation, not the cron cadence, defines correctness — the cron is only a trigger, so a missed tick can be safely re-run without duplicate emails thanks to the digest_sent_at guard.


Related snips

Share this code

Here's the card — post it anywhere.

Weekly Digest Emails in Rails with a Cron Job and Query Scopes — share card
Link copied