Skip to content

Email digest scheduling (weekly/monthly) - #250

Merged
nwwatson merged 1 commit into
masterfrom
automated/issue-42-5baa34
Sep 18, 2026
Merged

nwwatson merged 1 commit into
masterfrom
automated/issue-42-5baa34

Conversation

@nwwatson

Copy link
Copy Markdown
Owner

Summary

Subscribers can now choose how often they hear about new posts: every new post (default, current behavior), a weekly digest, a monthly digest, or no post emails. Digests are recurring Solid Queue jobs that gather every post published since the subscriber's last digest into one branded email, and are skipped when nothing new was published.

Changes

  • Subscriber::EmailPreferences: new email_frequency enum (immediate/weekly/monthly/none, default immediate) and a last_digest_at delivery cursor. The cursor moves when the frequency changes. Switching from per-post emails to a digest starts from now, so posts already emailed aren't repeated. Switching between weekly and monthly keeps the cursor, so no posts are skipped.
  • SendDigestsJob: scheduled in config/recurring.yml (weekly: Mondays 08:00; monthly: the 1st at 08:00, server time zone/UTC). Lists up to 20 posts plus a "See N more posts" link, and moves each recipient's cursor to the run time. A retried or duplicate run finds nothing new and sends nothing.
  • DigestMailer#digest: uses the existing branded mailer layout. Each post shows its featured image (non-WebP variant for email clients), title, byline/date, excerpt and a "Read more" link. Nothing is sent if the subscriber unsubscribed or every post was unpublished after the digest was queued.
  • SendPostNotificationsJob now emails only immediate subscribers.
  • /email-preferences page (EmailPreferencesController): reached through a signed link in post email footers (new "Email preferences" link next to Unsubscribe), a "switch to a digest instead" link on the unsubscribe page, or directly by a signed-in subscriber. email-preferences is now a reserved page slug.
  • Admin subscriber page shows the subscriber's email frequency; the JSON export includes it.
  • en + es translations.

Two existing bugs fixed along the way

  1. config/recurring.yml tasks never ran in production. Solid Queue uses config[Rails.env] whenever that key exists, and the file had a production: block. So in production only clear_solid_queue_finished_jobs loaded: publish_scheduled_posts, send_scheduled_newsletters and sync_stripe_subscriptions were silently skipped (and the new digest jobs would have been too). The file now defines the shared tasks under a default: &default anchor that every environment merges in. I checked the task list each environment resolves to with SolidQueue::Configuration.
  2. New-post notification emails linked to /posts/<slug>.<slug>. post_url(@post, slug: @post.slug) puts the record in the :format slot. Fixed in the notification templates, with a test that fails on the old code. The same pattern still exists in the RSS feed, sitemap and comment-reply email; I've left those for a separate change so this PR stays focused.

Assumptions (the issue left these open)

  • none stops post emails only. Newsletters still go to every confirmed subscriber, because unsubscribing already covers those.
  • The issue's "subscriber count / engagement summary" line is read as a post-count summary ("Here are the 7 posts published since your last digest"). Showing the site's subscriber count to readers seemed out of place.
  • The excerpt is Post#seo_description, which is already public in the page's meta tags, so members-only/paid posts show nothing beyond their normal teaser.
  • There is no subscriber account area in the app, so the preferences page is reached by a signed, 30-day link (separate token purpose from unsubscribe) or by a signed-in subscriber.

Documentation

  • README.md: new "Email Digests" feature bullet, plus a note that recurring email jobs run on config/recurring.yml cron schedules in UTC.
  • CLAUDE.md: new "Email Frequency & Digests" section (cursor semantics, token, mailer links), the recurring.yml environment-block gotcha under Background Jobs, and the new concern in the model list.

Testing

  • Open a post notification email's "Email preferences" link, or run Subscriber.first.email_preferences_token in the console and visit /email-preferences?token=…. Pick "Weekly digest" and save.
  • Run SendDigestsJob.perform_now("weekly") in the console. The digest appears in the mail log / letter opener, and running it again sends nothing.
  • I checked the preferences page (light/dark) and a rendered digest (with a featured image, the "See N more" button and the footer links) in the browser against seed data.
  • New tests: model concern, job (frequency filtering, unconfirmed/unsubscribed, cursor, idempotency, cap, drafts/scheduled), mailer (content, images, links, branding, late unsubscribe/unpublish), controller (token, signed-in, invalid token, invalid frequency), and updated notification/unsubscribe tests.
  • bin/rails test: 1498 runs, 0 failures. RuboCop: no offenses. Brakeman, bundler-audit, importmap audit: clean.

Closes #42

🤖 Generated with Claude Code

Subscribers can choose how often they hear about new posts: every new
post (default), a weekly digest, a monthly digest, or no post emails.

- Subscriber::EmailPreferences adds the email_frequency enum and a
  last_digest_at delivery cursor, moved on frequency changes so switching
  never resends or drops posts
- SendDigestsJob (weekly Mondays 08:00, monthly on the 1st) emails a
  branded DigestMailer digest of posts published since each subscriber's
  last digest, skipping subscribers with nothing new; idempotent on retry
- SendPostNotificationsJob now only emails immediate subscribers
- /email-preferences page, reached via a signed link in post email
  footers, the unsubscribe page, or when signed in
- Restructure config/recurring.yml with a shared anchor: Solid Queue
  loads only the current environment's block, so the top-level tasks
  (scheduled post publishing, scheduled newsletters, Stripe sync) were
  never loaded in production
- Fix malformed /posts/<slug>.<slug> links in new-post notification emails

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nwwatson nwwatson self-assigned this Sep 18, 2026
@nwwatson
nwwatson merged commit 289c0e8 into master Sep 18, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Email digest scheduling (weekly/monthly)

1 participant