Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ app/models/membership_tier.rb # MembershipTier: paid membership plans (nam
app/models/membership_tier/syncable.rb # module MembershipTier::Syncable (syncs to Stripe products/prices)
app/models/membership.rb # Membership: subscriber ↔ tier link with Stripe subscription state
app/models/subscriber/billable.rb # module Subscriber::Billable (memberships, paid_member?, stripe_customer_id)
app/models/subscriber/email_preferences.rb # module Subscriber::EmailPreferences (email_frequency enum, digest cursor, signed preferences token)
app/models/post/accessible.rb # module Post::Accessible (visibility enum, content gating)
app/models/site_setting/payment_configuration.rb # module SiteSetting::PaymentConfiguration (Stripe keys)
```
Expand All @@ -123,7 +124,7 @@ Site-wide locale configured via `SiteSetting.locale` (default: `"en"`). The `Sit
Dates and times are rendered via `l(value, format: :short|:long|:with_time)`, never `strftime`, so they follow the site locale. Named formats live under `date.formats`/`time.formats` in each locale file — `date.formats` (`short`, `long`) for date-only values and `time.formats` (`short`, `long`, `with_time`) for values that include a time of day. Because there's no `rails-i18n` gem, each locale file also defines its own `date.month_names`/`abbr_month_names`/`day_names`/`abbr_day_names` and `time.am`/`pm` — without these, `%B`/`%A`/`%p` in a format string raise `I18n::MissingTranslationData` for any locale ActiveSupport doesn't ship a default for (i.e. anything but English). **`l` dispatches on the argument's class**: a `Date` uses `date.formats`, while a `Time`/`ActiveSupport::TimeWithZone` uses `time.formats` — so a date-only display of a timestamp column (`created_at`, `published_at`, etc.) must call `l(value.to_date, format: :short)`, not `l(value, format: :short)`, or it silently pulls from `time.formats` instead. `ApplicationHelper#published_on(record, **html_options)` wraps this for the `<time datetime="...">` tag used by post card partials (`posts/_post`, `posts/_related_posts`, `posts/show`, `admin/posts/_preview`).

### Background Jobs
Solid Queue (database-backed). Jobs organized by domain in `app/jobs/`. Recurring tasks configured in `config/recurring.yml`.
Solid Queue (database-backed). Jobs organized by domain in `app/jobs/`. Recurring tasks configured in `config/recurring.yml`. **Solid Queue loads only the block named after `Rails.env` when one exists** (it does not merge top-level tasks into it), so tasks every environment needs go under the `default: &default` anchor that each environment block merges with `<<: *default`; production-only tasks sit in the `production:` block beside the merge. A task added at the top level would silently never run in production.

### SiteSetting Caching
`SiteSetting.current` is memoized on `Current.site_setting` (`app/models/current.rb`) for the lifetime of a request or job — `first_or_create!` only runs once per request instead of on every call. An `after_commit` callback on `SiteSetting` clears the memoized value so an update within the same request is not stale. `SiteHelper` exposes a `site_setting` helper method that views should call instead of `SiteSetting.current` directly. `Newsletter::Templatable#resolved_*` methods accept an optional `site` argument so `email_settings` can pass down a single fetched record rather than re-querying per field.
Expand Down Expand Up @@ -213,6 +214,13 @@ Profile data (bio, avatar, social links) lives on the `Identity` model via `Iden
### Mailer Branding & Layout
`SiteSetting::EmailBranding#email_branding` resolves the seven branding fields (accent/background/body/heading colors with `DEFAULT_*` constant fallbacks, font stack, footer text, site name, logo URL) into one hash. `ApplicationMailer#load_email_branding` sets `@email_settings` from it (plus per-field ivars the individual mailer templates read); `Newsletter::Templatable#email_settings` merges the same hash with newsletter-specific overrides (resolved template/accent color/preheader text, social links) rather than re-deriving the color fallbacks. `ApplicationMailer#set_list_unsubscribe_headers(url)` is shared by `NewsletterMailer#campaign` and `PostNotificationMailer#new_post`, the only two mailers that set `List-Unsubscribe`/`List-Unsubscribe-Post`. `app/views/mailer/_cta_button.html.erb` (`url:, label:, color:`) is the shared CTA table used by `post_notification_mailer/new_post`, `subscriber_mailer/confirmation`, `subscriber_mailer/magic_link`, and `comment_mailer/reply_notification`. `app/views/mailer/_footer.html.erb` (`settings:`, reads `@unsubscribe_url` directly) is shared by the three newsletter templates (`newsletter_mailer/_template_*`) and by `layouts/mailer.html.erb` (used by `post_notification_mailer`, `subscriber_mailer`, `comment_mailer`) — the layout keeps its own header markup since it supports a logo fallback the newsletter's minimal template doesn't.

### Email Frequency & Digests
`Subscriber::EmailPreferences` adds `email_frequency` (enum `immediate`/`weekly`/`monthly`/`none`, `prefix: :email`, `validate: true`, default `immediate`). The frequency governs **post emails only**: `SendPostNotificationsJob` sends to `Subscriber.confirmed.email_immediate`, and `SendDigestsJob` covers `weekly`/`monthly`. Newsletters ignore it (`Newsletter::Sendable#target_subscribers` is unchanged), so `none` means "no post emails", not unsubscribed.

`SendDigestsJob.perform(frequency)` runs from `config/recurring.yml` (`"0 8 * * 1"` weekly, `"0 8 1 * *"` monthly, server time zone). `subscribers.last_digest_at` is a **delivery cursor**, meaning "posts published before this have reached the subscriber", not strictly "when a digest was last sent". `#digest_window_start(now)` is `last_digest_at || now - period`. The job lists `Post.live.where(published_at: since...now)` (half-open, so consecutive windows neither overlap nor gap), caches the lookup per distinct window start, enqueues `DigestMailer.digest(subscriber, post_ids.first(POST_LIMIT), total_count)`, then `update_all`s `last_digest_at` to the run's `now` for that batch. A retried run therefore finds empty windows, and a subscriber with no new posts keeps their cursor. A `before_save` moves the cursor when the frequency changes: from `immediate` it jumps to now (those posts were already emailed individually), from `none` it clears (falls back to the last period), and between `weekly` and `monthly` it is kept. `DigestMailer` reloads `Post.live` at delivery time and returns without mailing if the subscriber has since unsubscribed or every post was unpublished. It shows `seo_description` as the excerpt (already public in meta tags, so it doesn't leak gated content beyond what the page head does), and uses a non-WebP featured-image variant because email clients can't render WebP.

Subscribers manage the setting at `/email-preferences` (`EmailPreferencesController`; `email-preferences` is a reserved `Page` slug). Access is either a signed `token` param (`Subscriber#email_preferences_token` / `.find_by_email_preferences_token`, message verifier purpose `"email_preferences"`, 30-day expiry, separate from the `"unsubscribe"` token) or a signed-in `current_subscriber`. `PostNotificationMailer` and `DigestMailer` set `@email_preferences_url`, which `mailer/_footer` renders beside the unsubscribe link, and the unsubscribe confirmation page links to it as an alternative. In mailer templates build post links with `post_url(slug: post.slug)`: `post_url(post, slug: ...)` puts the record in the `:format` slot and yields `/posts/<slug>.<slug>`.

### Paid Memberships (Stripe)
Prose supports Ghost-style paid memberships with Stripe integration. Feature is gated — UI only appears when Stripe keys are configured in `SiteSetting`. Direct Stripe API keys (not Stripe Connect), stored encrypted like AI keys. 3-level post visibility: `public` (default), `members_only` (free sign-in), `paid_only` (subscription required). `PaymentService::Stripe` handles checkout sessions, billing portal, subscriptions, and webhook event construction — see Email & Payment Provider Adapters above. `StripeWebhookJob` processes checkout completions, subscription updates/deletions, and payment failures (idempotent) — it's a thin dispatcher that delegates status mapping and attribute syncing to `Membership::StripeSyncable`. `SyncStripeSubscriptionJob` runs daily to keep membership status in sync, also via `StripeSyncable`. Admin controllers at `/admin/payment_settings`, `/admin/membership_tiers`, `/admin/memberships`, `/admin/revenue`. Public pricing page at `/memberships`. Content gating in `posts/show` — `PostsController` sets `@can_view` via `MembershipAccess` concern. Query objects: `RevenueQuery` (MRR, ARR, churn) and `MembershipGrowthQuery` (new/canceled/net by month).

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A self-hosted blogging platform built with Ruby on Rails 8.1 and the Solid stack
- **Content Export** — Download your whole site as a Markdown zip (one file per post/page with YAML front matter, plus images) or a full JSON backup including subscribers; exports run in the background from **Admin → Export**
- **Content Organization** — Categories, tags with searchable combo box and inline creation
- **Reader Engagement** — Comments with threading and moderation, loves, social share buttons, subscriber magic-link auth, email notifications
- **Email Digests** — Subscribers choose how often they hear about new posts at `/email-preferences` (linked from every post email and the unsubscribe page): every new post, a weekly digest (Mondays 08:00), a monthly digest (the 1st, 08:00), or no post emails; newsletters are unaffected. Digests use the site's email branding and are skipped when nothing new was published
- **Reading List** — Readers bookmark posts to `/reading-list`; saved on the device with no account, and synced across devices once they subscribe and sign in
- **Social Embeds** — X/Twitter and YouTube via oEmbed
- **Analytics** — Dashboard with view tracking, subscriber growth, post engagement
Expand Down Expand Up @@ -256,6 +257,8 @@ env:
SMTP_FROM: noreply@yourdomain.com
```

Recurring email jobs (post digests, scheduled newsletters) run on the schedules in `config/recurring.yml`. Cron times use the server time zone, which is UTC in the Docker image.

### Optional: S3-Compatible Storage

For file uploads stored in S3 instead of local disk:
Expand Down
65 changes: 65 additions & 0 deletions app/assets/tailwind/components/_email-preferences.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* ==========================================================================
* .email-preferences — Subscriber email frequency page BEM block
*
* Usage:
* .email-preferences Page container
* .email-preferences__heading Page heading
* .email-preferences__message Intro text
* .email-preferences__notice Muted note (e.g. already unsubscribed)
* .email-preferences__options Radio option list (fieldset)
* .email-preferences__option One selectable option (label)
* .email-preferences__radio Radio input
* .email-preferences__option-label Option name
* .email-preferences__option-hint Option description
* .email-preferences__error Validation error text
* .email-preferences__action Submit button container
* .email-preferences__footer Secondary links (unsubscribe/return)
* ========================================================================== */

.email-preferences {
@apply mx-auto max-w-md py-16;
}

.email-preferences__heading {
@apply text-center text-2xl font-bold text-gray-900 dark:text-gray-100;
}

.email-preferences__message {
@apply mt-4 text-center text-gray-600 dark:text-gray-400;
}

.email-preferences__notice {
@apply mt-4 rounded-md bg-gray-100 px-4 py-3 text-center text-sm text-gray-600 dark:bg-gray-800 dark:text-gray-300;
}

.email-preferences__options {
@apply mt-8 space-y-3;
}

.email-preferences__option {
@apply flex cursor-pointer items-start gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:border-gray-400 has-[:checked]:border-gray-900 dark:border-gray-700 dark:hover:border-gray-500 dark:has-[:checked]:border-gray-100;
}

.email-preferences__radio {
@apply mt-1 accent-gray-900 dark:accent-gray-100;
}

.email-preferences__option-label {
@apply block font-medium text-gray-900 dark:text-gray-100;
}

.email-preferences__option-hint {
@apply block text-sm text-gray-500 dark:text-gray-400;
}

.email-preferences__error {
@apply mt-4 text-center text-sm text-red-600 dark:text-red-400;
}

.email-preferences__action {
@apply mt-6 text-center;
}

.email-preferences__footer {
@apply mt-6 text-center text-sm text-gray-500 dark:text-gray-400;
}
1 change: 1 addition & 0 deletions app/assets/tailwind/components/_index.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@
@import "./_subscribe-form.css";
@import "./_text-link.css";
@import "./_toc.css";
@import "./_email-preferences.css";
@import "./_unsubscribe-page.css";
24 changes: 24 additions & 0 deletions app/controllers/email_preferences_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Lets a subscriber choose how often they hear about new posts. Reached from
# the signed link in post emails (no sign-in needed) or, for a signed-in
# subscriber, directly.
class EmailPreferencesController < ApplicationController
before_action :set_subscriber

def show
end

def update
if @subscriber.update(params.expect(subscriber: [ :email_frequency ]))
redirect_to email_preferences_path(token: params[:token].presence), notice: t("flash.email_preferences.updated")
else
render :show, status: :unprocessable_entity
end
end

private

def set_subscriber
@subscriber = params[:token].present? ? Subscriber.find_by_email_preferences_token(params[:token]) : current_subscriber
redirect_to root_path, alert: t("flash.email_preferences.invalid_link") unless @subscriber
end
end
40 changes: 40 additions & 0 deletions app/jobs/send_digests_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Emails every confirmed subscriber on the given digest frequency ("weekly" or
# "monthly") a summary of the posts published since their last digest.
# Scheduled in config/recurring.yml.
#
# Each subscriber's `last_digest_at` is advanced to this run's timestamp once
# their digest is enqueued, so a retried or duplicated run finds an empty window
# instead of re-sending. Subscribers with no new posts are skipped and keep
# their cursor, so the next run still covers everything since their last digest.
class SendDigestsJob < ApplicationJob
queue_as :default

POST_LIMIT = 20

def perform(frequency)
raise ArgumentError, "Unknown digest frequency: #{frequency}" unless Subscriber::EmailPreferences::DIGEST_PERIODS.key?(frequency)

now = Time.current
# Subscribers who received the same digest share a window start, so this
# usually runs one posts query per run rather than one per subscriber.
post_ids_since = Hash.new { |cache, since| cache[since] = live_post_ids(since, now) }

Subscriber.confirmed.where(email_frequency: frequency).in_batches do |batch|
sent_ids = batch.filter_map do |subscriber|
post_ids = post_ids_since[subscriber.digest_window_start(now)]
next if post_ids.empty?

DigestMailer.digest(subscriber, post_ids.first(POST_LIMIT), post_ids.size).deliver_later
subscriber.id
end

Subscriber.where(id: sent_ids).update_all(last_digest_at: now) if sent_ids.any?
end
end

private

def live_post_ids(since, now)
Post.live.where(published_at: since...now).by_publication_date.pluck(:id)
end
end
2 changes: 1 addition & 1 deletion app/jobs/send_post_notifications_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class SendPostNotificationsJob < ApplicationJob

def perform(post_id)
post = Post.find(post_id)
Subscriber.confirmed.find_each do |subscriber|
Subscriber.confirmed.email_immediate.find_each do |subscriber|
PostNotificationMailer.new_post(subscriber, post).deliver_later
end
end
Expand Down
35 changes: 35 additions & 0 deletions app/mailers/digest_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
class DigestMailer < ApplicationMailer
FEATURED_IMAGE_WIDTH = 1072 # 2x the 536px content column, for retina screens

helper_method :digest_featured_image_url

# post_ids arrive newest first; total_count may exceed them when the job
# capped the list, so the email can point at the rest of the archive.
def digest(subscriber, post_ids, total_count = post_ids.size)
return if subscriber.unsubscribed?

@subscriber = subscriber
@posts = Post.live.where(id: post_ids).with_author.with_attached_featured_image.by_publication_date.to_a
return if @posts.empty? # unpublished since the digest was queued

@total_count = total_count
@unsubscribe_url = generate_unsubscribe_url(subscriber)
@email_preferences_url = email_preferences_url(token: subscriber.email_preferences_token)
load_email_branding

set_list_unsubscribe_headers(@unsubscribe_url)

mail(to: subscriber.email, subject: t("digest_mailer.digest.subject", count: total_count, site_name: @site_name))
end

private

# Email clients can't render WebP, so the variant keeps the original format.
def digest_featured_image_url(post)
return unless post.featured_image.attached?

image = post.featured_image
image = image.variant(resize_to_limit: [ FEATURED_IMAGE_WIDTH, nil ]) if image.variable?
polymorphic_url(image)
end
end
1 change: 1 addition & 0 deletions app/mailers/post_notification_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ def new_post(subscriber, post)
@subscriber = subscriber
@post = post
@unsubscribe_url = generate_unsubscribe_url(subscriber)
@email_preferences_url = email_preferences_url(token: subscriber.email_preferences_token)
load_email_branding

set_list_unsubscribe_headers(@unsubscribe_url)
Expand Down
2 changes: 1 addition & 1 deletion app/models/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class Page < ApplicationRecord
RESERVED_SLUGS = %w[
admin posts authors categories tags subscriptions feed sitemap robots up mcp
subscriber_session handle handle_availability unsubscribe webhooks
reading-list
reading-list email-preferences
].freeze

validates :slug, exclusion: { in: RESERVED_SLUGS, message: "is reserved" }
Expand Down
1 change: 1 addition & 0 deletions app/models/subscriber.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class Subscriber < ApplicationRecord
include Authenticatable
include Billable
include EmailPreferences
include IdentityBacked

belongs_to :source_post, class_name: "Post", optional: true
Expand Down
55 changes: 55 additions & 0 deletions app/models/subscriber/email_preferences.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
module Subscriber::EmailPreferences
extend ActiveSupport::Concern

# How often a subscriber hears about new posts. `none` only stops post
# emails (per-post notifications and digests); newsletters still arrive until
# the subscriber unsubscribes.
DIGEST_PERIODS = { "weekly" => 1.week, "monthly" => 1.month }.freeze
PREFERENCES_TOKEN_EXPIRY = 30.days

included do
enum :email_frequency, { immediate: 0, weekly: 1, monthly: 2, none: 3 }, prefix: :email, validate: true

before_save :move_digest_cursor, if: :will_save_change_to_email_frequency?
end

class_methods do
def find_by_email_preferences_token(token)
find_by(id: preferences_verifier.verified(token))
end

def preferences_verifier
Rails.application.message_verifier("email_preferences")
end
end

def email_preferences_token
self.class.preferences_verifier.generate(id, expires_in: PREFERENCES_TOKEN_EXPIRY)
end

def email_digest?
DIGEST_PERIODS.key?(email_frequency)
end

# Start of the publication window the subscriber's next digest covers.
# `last_digest_at` marks how far posts have already been delivered; with no
# cursor the digest covers the most recent period.
def digest_window_start(now = Time.current)
last_digest_at || now - DIGEST_PERIODS.fetch(email_frequency)
end

private

# Switching from per-post emails to a digest starts the window now, since
# every earlier post was already emailed; coming back from `none` falls back
# to the most recent period. Switching between digest frequencies keeps the
# cursor so no posts fall through the gap.
def move_digest_cursor
self.last_digest_at =
case email_frequency_in_database
when "immediate" then Time.current
when "none" then nil
else last_digest_at
end
end
end
1 change: 1 addition & 0 deletions app/services/exports/json_exporter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def subscribers
email: subscriber.email,
confirmed_at: subscriber.confirmed_at&.iso8601,
unsubscribed_at: subscriber.unsubscribed_at&.iso8601,
email_frequency: subscriber.email_frequency,
created_at: subscriber.created_at.iso8601,
label_ids: subscriber.subscriber_labelings.map(&:subscriber_label_id).sort
}
Expand Down
Loading
Loading