Weekly Digest Email — Feature & Architecture Guide
Overview
The weekly digest email is an async outreach feature that summarizes a user's weekly progress, goals, and activity. It's evaluated hourly by Social::WeeklyDigestEnqueueJob, which gates sends behind an internal allowlist, per-user delivery-day and local-hour matching, a configurable cohort rollout (DIGEST_ROLLOUT_DENOMINATOR), and a 30-day activity threshold — then hands off to Social::WeeklyDigestSendJob to assemble and deliver the email. Users can unsubscribe via a one-tap email link, which leads to a confirmation page protecting against accidental unsubscribes and iframe-based auto-follow.
Status: Phase 88 complete (v1.22, PR #744) shipped the confirmation page and unsubscribe flow. Phase 87 later rearchitected the send path to hourly per-user-timezone dispatch with an internal allowlist and activity gate (see Cohort-Based Rollout below).
Email Delivery
When Emails Are Sent
- Cron trigger:
Social::WeeklyDigestEnqueueJobruns hourly (config/cronotab.rb) and matches each user's local time against a target hour and delivery day inside the job — it does not fire once at a fixed UTC time. See Cohort-Based Rollout for the full gate waterfall (internal allowlist, delivery day, local hour, cohort, activity threshold). - Send job: Once a user passes every gate,
Social::WeeklyDigestEnqueueJobenqueuesSocial::WeeklyDigestSendJob, which assembles the digest data, delivers the mail, and recordslast_digest_sent_at(with a 6-day guard against double-sends in the same week). - Payload: Each digest includes:
- Weekly insight text (AI-generated fallback available)
- Streak summary (active habit count)
- Weekly XP earned
- Week label (e.g., "May 12 – 18")
- Call-to-action link (one of several deep links, click-tracked)
- List of active allies (accountability partners and followers)
Mailer Implementation
File: rails_api/app/mailers/user_mailer.rb
def weekly_digest(user, digest_data)
@user = user
@insight_text = digest_data.fetch(:insight_text)
@streak_count = digest_data.fetch(:streak_count)
@weekly_xp = digest_data.fetch(:weekly_xp)
@week_label = digest_data.fetch(:week_label)
@cta_url = click_redirect_url_for(user, digest_data.fetch(:cta_url), :cta)
@allies = digest_data.fetch(:allies, [])
@unsubscribe_url = unsubscribe_url_for(user)
mail(to: @user.email, subject: "Your week with Objectuve — #{@week_label}")
endKey behaviors:
- CTA URL goes through
click_redirect_url_for()— signs it with a 30-day TTL for click tracking - Unsubscribe URL goes through
unsubscribe_url_for()— generates a signed token with 30-day TTL, appends&confirm=yesfor one-tap UX - Email template:
rails_api/app/views/user_mailer/weekly_digest.html.erb
Template
The email template renders:
- Logo and greeting
- Weekly insight card with AI coach context (or fallback text if AI unavailable)
- Streak summary and weekly XP badge
- Ally kudos section (recent encouragements/reactions from accountability partners)
- Single primary CTA (e.g., "View your progress" button, deep-linked to dashboard or insights)
- Secondary CTAs: "Manage email settings" link
- Footer with unsubscribe link
Unsubscribe Flow
Overview
Users unsubscribe via a secure signed link in the email footer. The flow has two parts:
- Email link — carries
&confirm=yesparam for one-tap UX (legitimate email clicks land directly on success) - Confirmation page — shown when user visits the bare unsubscribe URL without
confirm=yes, protecting against accidental unsubscribes and iframe-rendering clients
Email Link
URL pattern:
GET /unsubscribe?token={signed_token}&confirm=yesToken details:
- Generated via
Rails.application.message_verifier(:weekly_digest_unsubscribe).generate(user.public_id, expires_in: 30.days) - Signed with HMAC-SHA256 using Rails secrets
- Expires in 30 days
- Decoded back to
user.public_idon the backend
Code (user_mailer.rb):
def unsubscribe_url_for(user)
token = Rails.application.message_verifier(:weekly_digest_unsubscribe)
.generate(user.public_id, expires_in: 30.days)
"#{ENV.fetch('API_URL', 'http://localhost:3000')}/unsubscribe?token=#{CGI.escape(token)}&confirm=yes"
endConfirmation Page (:awaiting_confirmation state)
Route: GET /unsubscribe?token={token} (without ?confirm=yes)
When a user visits the unsubscribe URL without the confirm=yes parameter, they see a confirmation page.
Why this exists: Gmail and other email clients render emails in iframes and may follow links programmatically before a user clicks. The confirmation page protects against two specific scenarios:
- Direct URL manipulation — a user or crawler visits the bare
/unsubscribe?token=…(e.g., copied from browser history, a forwarded URL that lost the query string, or a security scanner) - Iframe-rendering clients — email clients that render the full email page in a sandboxed iframe may load the unsubscribe URL before the user actively clicks
Important: Bot-prefetch (link-checker) protection is not a goal of this gate — the email link itself already carries &confirm=yes per the one-tap UX design, so legitimate prefetchers following the email link would land on :success, which is the intended behavior.
Page layout:
┌────────────────────────────────┐
│ [Objectuve Logo] │
│ │
│ Unsubscribe from your │
│ weekly digest? │
│ │
│ Confirm below — we'll stop │
│ sending weekly digests. You │
│ can turn them back on anytime │
│ from Settings. │
│ │
│ [ Yes, unsubscribe ] │
│ │
│ Manage email settings │
│ │
│ © 2026 Objectuve... │
└────────────────────────────────┘Microcopy (brand voice — friendly, no guilt-trip):
- H1: "Unsubscribe from your weekly digest?"
- Body: "Confirm below — we'll stop sending weekly digests. You can turn them back on anytime from Settings."
- Primary CTA: "Yes, unsubscribe" —
<a href="/unsubscribe?token=…&confirm=yes">(plain link, no form or JavaScript) - Secondary link: "Manage email settings" — opens
/settingsin a new tab
No JavaScript: The primary CTA is a plain <a href> link with no form submission or loading state. Fully functional with JavaScript disabled.
State Flow
Possible states in UnsubscribesController#show:
| State | Trigger | Display | Next action |
|---|---|---|---|
:awaiting_confirmation | GET /unsubscribe?token=… (no confirm param) | Confirmation page with "Yes, unsubscribe" button | User clicks button → sends GET /unsubscribe?token=…&confirm=yes |
:success | GET /unsubscribe?token=…&confirm=yes (valid token) | Success message: "You've unsubscribed from weekly digests" | User can re-subscribe via Settings or email re-subscription link |
:resubscribed | POST /unsubscribe?token=… (re-subscription via email link) | Success message: "You're subscribed again" | — |
:expired | Invalid/expired token or token verification failure | Error message: "This link has expired. Visit Settings to manage email preferences." | User navigates to Settings |
Controller implementation (rails_api/app/controllers/unsubscribes_controller.rb):
def show
@state = resolve_state
render :show
end
private
def resolve_state
user = verified_user
return :expired unless user
# No confirm param → show confirmation page
return :awaiting_confirmation unless params[:confirm] == 'yes'
# confirm=yes param → mutate and show success
detail = user.details || user.build_details(data: {})
detail.weekly_digest_enabled = false
detail.save!
:success
end
def verified_user
token = params[:token]
return nil if token.blank?
user_public_id = Rails.application.message_verifier(:weekly_digest_unsubscribe).verify(token)
User.public_find(user_public_id)
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
nil
endCohort-Based Rollout
Gate Mechanism
Social::WeeklyDigestEnqueueJob (app/jobs/social/weekly_digest_enqueue_job.rb) runs hourly and evaluates every user against a waterfall of gates, in order:
- Internal allowlist (master switch) —
ENV['WEEKLY_DIGEST_INTERNAL_ALLOWLIST']is a comma-separated list of emails. If this env var is empty, the job returns immediately and sends to nobody at all — this is not a bypass list, it's currently the feature's on/off switch. - Opted in —
user.details.weekly_digest_enabled != false(nil defaults to opted-in) - Delivery day matches — the user's
weekly_digest_delivery_daypreference (default'sun') matches their current local day - Local hour matches —
TARGET_HOUR = 9(09:00 in the user's own timezone, viauser.timezone_or_default) - Allowlist or cohort — if the user's email is on the internal allowlist, they always proceed; otherwise they must also pass the cohort gate (
user.id % DIGEST_ROLLOUT_DENOMINATOR == 0) - Activity threshold —
ACTIVITY_THRESHOLD = 3: the user must have at least 3GoalEvent/activity records in the last 30 days (user.events.where(created_at: 30.days.ago..).count >= ACTIVITY_THRESHOLD)
Only users who pass every gate get Social::WeeklyDigestSendJob.perform_later(user.public_id) enqueued.
def internal_allowlist
ENV.fetch('WEEKLY_DIGEST_INTERNAL_ALLOWLIST', '').split(',').map(&:strip).map(&:downcase).reject(&:empty?)
end
def cohort_in_rollout?(user)
denominator = ENV.fetch('DIGEST_ROLLOUT_DENOMINATOR', 0).to_i
return false if denominator.zero?
(user.id % denominator).zero?
end
def sufficient_activity?(user)
user.events.where(created_at: 30.days.ago..).count >= ACTIVITY_THRESHOLD
end| Denominator | Behavior | Example |
|---|---|---|
0 (default) | No cohort sends — non-allowlisted users never pass gate 5 | Safe default |
1 | 100% rollout for non-allowlisted users — all users sent | Full launch after validation |
10 | 10% cohort — every 10th user (by ID) sent | Staged rollout (50k users → 5k sent) |
PostHog events: the job reports both suppression reasons via PosthogNotifier (app/services/posthog_notifier.rb), a thin wrapper around the PostHog Ruby SDK that merges in a surface property on every call:
PosthogNotifier.capture(
distinct_id: user.public_id,
event: 'weekly_digest_suppressed',
properties: { user_public_id: user.public_id, reason: 'cohort_excluded' }
)
PosthogNotifier.capture(
distinct_id: user.public_id,
event: 'weekly_digest_suppressed',
properties: { user_public_id: user.public_id, reason: 'low_activity' }
)Production Deployment
Environment Variables
Set in Cloud Run production env (after CI passes + tag cut):
WEEKLY_DIGEST_INTERNAL_ALLOWLIST=admin@objectuve.com,test1@objectuve.com
DIGEST_ROLLOUT_DENOMINATOR=10WEEKLY_DIGEST_INTERNAL_ALLOWLIST must be non-empty or the job sends nothing at all, regardless of DIGEST_ROLLOUT_DENOMINATOR. Emails are matched case-insensitively and comma-split values are trimmed.
Staged rollout procedure:
- Initial validation: Set to
0after deploy (fully disabled, safe to monitor for errors) - Staged send: After 24–48 hours of log review, set to
10(10% of users receive digests) - Gradual expansion: Monitor error rates, bounce rates, unsubscribe rates:
- Day 1–2: 10% (validation)
- Day 3–4: 5% (broader cohort)
- Day 5+: 1% or 0 (100% or keep staged based on metrics)
- Emergency rollback: Set to
0if incidents detected
Alert thresholds:
- Mailer errors: ≥3% of sends
- Bounce rate: ≥5%
- Unsubscribe rate: ≥10% of recipients
Click Tracking
Signed CTA Links
Each CTA in the weekly digest is signed with a click_redirect verifier before being sent. This allows the backend to capture click events without requiring user login.
Flow:
- Mailer creates a deep link (e.g.,
https://app.objectuve.com/dashboard) click_redirect_url_for(user, url, :cta)wraps it:- Signs
user.public_idand the targeturlwith 30-day TTL - Returns:
GET /email/click?token={signed_token}
- Signs
- Email is sent with the signed
/email/clicklink - User clicks in email client
Email::ClickRedirectsController#showdecodes the token, capturesweekly_digest_clickedPostHog event with{ user_public_id:, link_kind: 'cta' }, then redirects to the original URL
Important: The unsubscribe URL does not go through click-redirect. It uses its own weekly_digest_unsubscribe verifier with no click tracking.
User Properties (GraphQL + PostHog)
GraphQL Schema
New fields on UserType:
| Field | Type | Resolver |
|---|---|---|
hasDigestEnabled | Boolean | object.details&.weekly_digest_enabled != false (nil = true) |
lastDigestSentAt | String (ISO 8601) | object.details&.last_digest_sent_at (nil if never sent) |
Usage:
query {
me {
id
hasDigestEnabled
lastDigestSentAt
}
}PostHog Sync
After user sync, the frontend calls syncDigestPropertiesToPosthog() to push both properties to PostHog:
// ionic_frontend/src/helpers/userToPosthog.ts
export function syncDigestPropertiesToPosthog(userData: UserType) {
try {
posthog.identify(userData.publicId, {
has_digest_enabled: userData.hasDigestEnabled ?? true,
last_digest_sent_at: userData.lastDigestSentAt ?? null
})
} catch (error) {
if (import.meta.env.DEV) console.warn('PostHog sync failed:', error)
}
}Staging-Only: Smoke Test Token Helper
Endpoint
Route: GET /smoke/unsubscribe_token (routed only in non-production environments)
Purpose: Generate a signed unsubscribe token for the Playwright smoke spec without requiring the mailer. Requires an X-Smoke-Secret header matching FALLBACK_AUTH_SECRET.
Request:
GET /smoke/unsubscribe_token?public_id={public_id}
X-Smoke-Secret: {FALLBACK_AUTH_SECRET}Response:
{
"token": "eyJfX3RhZ..."
}Controller: rails_api/app/controllers/smoke/unsubscribe_tokens_controller.rb
module Smoke
class UnsubscribeTokensController < ActionController::Base
skip_before_action :authenticate_user!, raise: false
before_action :verify_smoke_secret
def show
public_id = params[:public_id]
return render json: { error: 'Missing public_id' }, status: :unprocessable_entity if public_id.blank?
token = Rails.application.message_verifier(:weekly_digest_unsubscribe)
.generate(public_id, expires_in: 30.days)
render json: { token: token }
end
end
endThe Playwright spec builds the full unsubscribe URL itself (${API_URL}/unsubscribe?token=...) rather than receiving a pre-built url from the response.
Not available in production. The X-Smoke-Secret guard renders 401 Unauthorized if it doesn't match FALLBACK_AUTH_SECRET, and the route is not mounted in production.
Testing
RSpec Coverage
Files with tests:
spec/mailers/user_mailer_spec.rb— TTL, token generation, confirm=yes appendedspec/requests/unsubscribes_spec.rb— all 5 state transitions, valid/invalid tokens, expiryspec/requests/email/click_redirects_spec.rb— click tracking, whitelist validation, 422 on non-whitelistedspec/jobs/social/weekly_digest_enqueue_job_spec.rb— allowlist master switch, delivery-day/local-hour matching, cohort gate, activity threshold, PostHog eventsspec/jobs/social/weekly_digest_send_job_spec.rb— digest assembly, mailer delivery, double-send guard, PostHogweekly_digest_sentevent
Target coverage: ≥80% line / ≥75% branch on all files
Playwright Smoke Test
File: ionic_frontend/tests/smoke-playwright/specs/mood-lifecycle/14-weekly-digest-unsubscribe.spec.ts
Runs as a test.describe.serial block (order-dependent, single worker) rather than independent Cypress specs, because later tests depend on state committed by earlier ones and share a token/fallbackToken pair set up in the first test:
- Setup — calls
syncUserto get the demo account'spublicId, re-enables the digest viasetWeeklyDigestPreferences(enabled: true)(cleanup from any prior run), then requests a token fromGET /smoke/unsubscribe_token(with theX-Smoke-Secretheader) - Awaiting confirmation — visits the bare unsubscribe URL and asserts the confirmation page copy is visible
- Success — clicks "Yes, unsubscribe" and asserts the success copy ("You're unsubscribed") is visible
- GraphQL check — queries
syncUser { user { hasDigestEnabled } }and assertsfalse - Cleanup — re-enables the digest via
setWeeklyDigestPreferences(enabled: true)so the shared demo account is ready for the next run
Gated with test.skip(process.env.SMOKE_ENV === 'production', …) since the smoke-token endpoint is staging-only. Runs after preview and production deploys via the Playwright smoke GitHub Actions workflow.
Known Behaviors & Gotchas
Token Expiry Edge Case
Tokens expire after 30 days. If a user receives a digest and waits 31 days before clicking unsubscribe, the link will return the :expired state. This is intentional — it prevents stale tokens from being replayed.
User recovery: Clicking "Visit Settings" on the expired page lets them unsubscribe via the app (if they log in).
Resubscription
The POST /unsubscribe endpoint (separate from the GET confirmation flow) handles re-subscription. This is used by re-subscription links sent in follow-up emails (out of Phase 88 scope).
Iframe-Rendering Clients
Some email clients (e.g., Apple Mail's preload) may load email URLs in a sandbox before user interaction. The confirmation gate prevents accidental unsubscribes in these environments. This is a browser/email-client responsibility; we cannot fully mitigate on the backend.
Allowlist Format
WEEKLY_DIGEST_INTERNAL_ALLOWLIST is a comma-separated string of email addresses:
WEEKLY_DIGEST_INTERNAL_ALLOWLIST="test1@example.com,test2@example.com,admin@objectuve.com"Each entry is .stripped and downcased before comparison, so surrounding whitespace and casing are forgiving. An empty or unset value disables the entire job for every user — see Gate Mechanism.
Historical Context
- Phase 86 (v1.21): Weekly digest email feature released; basic unsubscribe link
- Phase 87: Send path rearchitected from a single weekly Crono dispatch to an hourly job with per-user-timezone/delivery-day matching, an internal allowlist master switch (
WEEKLY_DIGEST_INTERNAL_ALLOWLIST), a 30-day activity threshold, and a dedicatedSocial::WeeklyDigestSendJobfor assembly + delivery - Phase 88 (v1.22): Confirmation page added, unsubscribe flow hardened, click tracking, cohort rollout, PostHog sync (May 2026)
See Also
- Notifications — in-app notification system
- User Profile & Settings — user preferences and email settings management
- Gamification Engine — XP and badge systems referenced in digest
Last updated: 2026-07-06