Skip to content

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::WeeklyDigestEnqueueJob runs 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::WeeklyDigestEnqueueJob enqueues Social::WeeklyDigestSendJob, which assembles the digest data, delivers the mail, and records last_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

ruby
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}")
end

Key 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=yes for 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:

  1. Email link — carries &confirm=yes param for one-tap UX (legitimate email clicks land directly on success)
  2. Confirmation page — shown when user visits the bare unsubscribe URL without confirm=yes, protecting against accidental unsubscribes and iframe-rendering clients

URL pattern:

GET /unsubscribe?token={signed_token}&confirm=yes

Token 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_id on the backend

Code (user_mailer.rb):

ruby
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"
end

Confirmation 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:

  1. 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)
  2. 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 /settings in 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:

StateTriggerDisplayNext action
:awaiting_confirmationGET /unsubscribe?token=… (no confirm param)Confirmation page with "Yes, unsubscribe" buttonUser clicks button → sends GET /unsubscribe?token=…&confirm=yes
:successGET /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
:resubscribedPOST /unsubscribe?token=… (re-subscription via email link)Success message: "You're subscribed again"
:expiredInvalid/expired token or token verification failureError message: "This link has expired. Visit Settings to manage email preferences."User navigates to Settings

Controller implementation (rails_api/app/controllers/unsubscribes_controller.rb):

ruby
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
end

Cohort-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:

  1. 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.
  2. Opted inuser.details.weekly_digest_enabled != false (nil defaults to opted-in)
  3. Delivery day matches — the user's weekly_digest_delivery_day preference (default 'sun') matches their current local day
  4. Local hour matchesTARGET_HOUR = 9 (09:00 in the user's own timezone, via user.timezone_or_default)
  5. 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)
  6. Activity thresholdACTIVITY_THRESHOLD = 3: the user must have at least 3 GoalEvent/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.

ruby
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
DenominatorBehaviorExample
0 (default)No cohort sends — non-allowlisted users never pass gate 5Safe default
1100% rollout for non-allowlisted users — all users sentFull launch after validation
1010% cohort — every 10th user (by ID) sentStaged 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:

ruby
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):

bash
WEEKLY_DIGEST_INTERNAL_ALLOWLIST=admin@objectuve.com,test1@objectuve.com
DIGEST_ROLLOUT_DENOMINATOR=10

WEEKLY_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:

  1. Initial validation: Set to 0 after deploy (fully disabled, safe to monitor for errors)
  2. Staged send: After 24–48 hours of log review, set to 10 (10% of users receive digests)
  3. 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)
  4. Emergency rollback: Set to 0 if incidents detected

Alert thresholds:

  • Mailer errors: ≥3% of sends
  • Bounce rate: ≥5%
  • Unsubscribe rate: ≥10% of recipients

Click Tracking

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:

  1. Mailer creates a deep link (e.g., https://app.objectuve.com/dashboard)
  2. click_redirect_url_for(user, url, :cta) wraps it:
    • Signs user.public_id and the target url with 30-day TTL
    • Returns: GET /email/click?token={signed_token}
  3. Email is sent with the signed /email/click link
  4. User clicks in email client
  5. Email::ClickRedirectsController#show decodes the token, captures weekly_digest_clicked PostHog 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:

FieldTypeResolver
hasDigestEnabledBooleanobject.details&.weekly_digest_enabled != false (nil = true)
lastDigestSentAtString (ISO 8601)object.details&.last_digest_sent_at (nil if never sent)

Usage:

graphql
query {
  me {
    id
    hasDigestEnabled
    lastDigestSentAt
  }
}

PostHog Sync

After user sync, the frontend calls syncDigestPropertiesToPosthog() to push both properties to PostHog:

typescript
// 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:

bash
GET /smoke/unsubscribe_token?public_id={public_id}
X-Smoke-Secret: {FALLBACK_AUTH_SECRET}

Response:

json
{
  "token": "eyJfX3RhZ..."
}

Controller: rails_api/app/controllers/smoke/unsubscribe_tokens_controller.rb

ruby
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
end

The 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 appended
  • spec/requests/unsubscribes_spec.rb — all 5 state transitions, valid/invalid tokens, expiry
  • spec/requests/email/click_redirects_spec.rb — click tracking, whitelist validation, 422 on non-whitelisted
  • spec/jobs/social/weekly_digest_enqueue_job_spec.rb — allowlist master switch, delivery-day/local-hour matching, cohort gate, activity threshold, PostHog events
  • spec/jobs/social/weekly_digest_send_job_spec.rb — digest assembly, mailer delivery, double-send guard, PostHog weekly_digest_sent event

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:

  1. Setup — calls syncUser to get the demo account's publicId, re-enables the digest via setWeeklyDigestPreferences(enabled: true) (cleanup from any prior run), then requests a token from GET /smoke/unsubscribe_token (with the X-Smoke-Secret header)
  2. Awaiting confirmation — visits the bare unsubscribe URL and asserts the confirmation page copy is visible
  3. Success — clicks "Yes, unsubscribe" and asserts the success copy ("You're unsubscribed") is visible
  4. GraphQL check — queries syncUser { user { hasDigestEnabled } } and asserts false
  5. 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:

bash
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 dedicated Social::WeeklyDigestSendJob for assembly + delivery
  • Phase 88 (v1.22): Confirmation page added, unsubscribe flow hardened, click tracking, cohort rollout, PostHog sync (May 2026)

See Also


Last updated: 2026-07-06

Loading…