Skip to content

v1.22 — Weekly Digest Email — Complete Delivery

Shipping the final piece of the weekly digest system: a confirmation-gated unsubscribe flow, click tracking for all email CTAs, cohort-based rollout, and PostHog observability.

Post-ship correction (2026-08-29, OBJ-2982): the DIGEST_ROLLOUT_DENOMINATOR cohort gate described throughout this page (Operational control surface, Key Decisions, Outcomes) was dead code from the moment Phase 87 shipped. A Phase-87 guard (return if allowlist.empty? in weekly_digest_enqueue_job.rb) predated Phase 88's cohort-gate design and was never reconciled with it — an empty WEEKLY_DIGEST_INTERNAL_ALLOWLIST aborted the job unconditionally regardless of the denominator, so the digest never sent to anyone, in any environment, until OBJ-2982's fix. The guard now reads return if allowlist.empty? && ENV.fetch('DIGEST_ROLLOUT_DENOMINATOR', 0).to_i.zero?, making the allowlist a true bypass and the denominator an independent cohort control — the design this page's Key Decisions section always intended. See docs/architecture/email.md for the corrected operational detail (under "Internal allowlist") and OBJ-2982 for the full evidence chain. The rest of this page is retained as the original shipped record.

Summary

Weekly Digest Email (v1.22) is a complete end-to-end feature spanning four phases. Phase 86 built the foundation (settings component, email templates, unsubscribe page), Phase 87 shipped the send loop (Crono-based enqueue, Sidekiq send jobs, AI insight generation, PostHog telemetry), Phase 88 added delivery polish (TTL-protected tokens, unsubscribe confirmation gate, click tracking, cohort denominator), and Phase 89 lifted the cohort gate to 100% and tuned the AI prompt based on early-send observations.

Phase 89 ships the full rollout (removing the weekly_digest_settings PostHog feature flag from the frontend), Penny's canonical content drop (subject line, body copy, fallback insight pool), AI prompt VERSION 2 with four drift-prevention guards, and the v3.9.178 release tag. All 4 phases delivered via 4 PRs (PR #732, PR #741, PR #744, PR #749) between May 15–17, 2026.

Total delivery time: 3 days from initial phase dispatch (Phase 86) through Phase 89 ramp + content.

Operational control surface: The cohort-based rollout gate (DIGEST_ROLLOUT_DENOMINATOR) allows staged deployment (0 = off, 1 = 100%, 10 = 10% cohort). An allowlist (email-based) short-circuits the gate for early access testing. Emergency rollback is ENV-only (no code deploy required).

Observability: 4 PostHog events (weekly_digest_suppressed, weekly_digest_sent, weekly_digest_insight_fallback, weekly_digest_clicked) feed activation and retention analysis. GraphQL user properties (hasDigestEnabled, lastDigestSentAt) sync to PostHog on every user sync mutation.

Goal

Ship the complete weekly digest delivery system by adding a confirmation-gated unsubscribe flow (protects against direct URL manipulation and iframe prefetch), email click tracking (signed redirects + PostHog event capture), and cohort-based rollout infrastructure. Enable operators to stage digest sends to 1% → 10% → 100% of users and capture user properties in PostHog for activation analysis.

Scope — What Shipped

Backend:

  • MessageVerifier with 30-day TTL on unsubscribe tokens; backward compatibility for pre-TTL tokens
  • UnsubscribesController#show confirm-gate logic; :awaiting_confirmation, :success, :resubscribed, :expired states
  • Email::ClickRedirectsController with signed token decoding, whitelist enforcement (app.objectuve.com, objectuve.com), and weekly_digest_clicked PostHog capture
  • /unsubscribe_url_for() mailer helper appends &confirm=yes for one-tap UX (email links bypass confirmation page)
  • /smoke/unsubscribe_token staging-only endpoint (non-production guard) for Cypress integration
  • Social::WeeklyDigestEnqueueJob cohort gate (cohort_in_rollout? predicate); DIGEST_ROLLOUT_DENOMINATOR ENV parsing (0 = emergency off, 1 = 100%, 10 = 10%); allowlist bypass
  • weekly_digest_suppressed PostHog event with reason: 'cohort_excluded' when user is outside cohort
  • has_digest_enabled + last_digest_sent_at GraphQL fields on UserType (with nil=true default for has_digest_enabled)
  • ≥80% line / 75% branch coverage on all new/modified Rails files

Frontend:

  • hasDigestEnabled + lastDigestSentAt in GraphQL SYNC_USER + GET_USER queries
  • syncDigestPropertiesToPosthog() helper in userToPosthog.ts (maps GraphQL fields to PostHog identify() call)
  • Cypress smoke spec 14-weekly-digest-unsubscribe.cy.ts covering full flow: confirm page, one-tap unsubscribe, GraphQL mutation, cleanup
  • Node task in cypress.smoke.config.ts for token generation (signing via Rails verifier)
  • ≥80% statement coverage Vitest on all new files

Documentation:

  • docs/features/weekly-digest-email.md — 650-line comprehensive guide covering Phase 88 additions:
    • :awaiting_confirmation state with UI copy, confirmation page layout, and CTA flow
    • ?confirm=yes parameter design (one-tap UX for email links vs. direct navigation)
    • Staging-only /smoke/unsubscribe_token helper route + Cypress integration
    • 30-day token TTL and flow diagrams
    • Cohort rollout formula, denominator gating (0/1/10), allowlist bypass
    • Click tracking architecture and PostHog event table
    • Production deployment procedure
  • Updated docs/features/index.md — weekly digest link in Engagement section
  • Updated docs/features/user-profile.md — Email Preferences subsection with weekly digest toggle
  • .planning/phases/88-weekly-digest-delivery/UI-SPEC.md — DESIGN-DRIFT corrections:
    • Clarified HTTP method as GET (not POST) for confirmation action
    • Corrected ?confirm=yes gate rationale (direct URL manipulation + iframe rendering, not bot-prefetch)
  • CHANGELOG.md v3.9.172 entry (GitHub Releases format)

Phases

PhaseNameStatusPRTagDeliverable
86Weekly Digest FoundationShipped#732v3.9.168Settings component, email templates, unsubscribe page
87Weekly Digest Send LoopShipped#741v3.9.171Crono + Sidekiq jobs, AI insight assembly, PostHog telemetry
88Weekly Digest Delivery — CompleteShipped#744v3.9.172Token TTL, confirm-gate, click redirect, GraphQL properties, cohort gate, smoke spec
89Weekly Digest Polish + Full RolloutShipped#749v3.9.178Flag retirement, content drop, prompt v2 tuning, user-facing CHANGELOG

Key Decisions

Architecture & delivery:

  • Fresh insights at send time, no table — Phase 86 decision: each digest fetches latest events and assembles a fresh payload at send time (not batch-pre-assembled). Avoids stale data and simplifies queue topology.
  • Hourly Crono enqueue with per-user-timezone matching — Phase 87 decision: run WeeklyDigestEnqueueJob every hour and let the job check user timezone + preferred day/time. Simpler than per-user cron schedules and handles timezone-crossing edge cases naturally.
  • 4 PostHog events for observability — Phase 87 decision: weekly_digest_suppressed (with reason enum), weekly_digest_sent, weekly_digest_insight_fallback, weekly_digest_clicked. Enough surface to measure adoption, retention impact, and AI reliability without excessive cardinality.
  • Signed redirect controller for click tracking — Phase 88 decision: route CTA links through /email/click?token=…&url=… with 30-day TTL. Allows click attribution without user login + fraud protection via signed tokens.

Unsubscribe & confirmation:

  • Confirmation gate without form — Phase 88 decision: GET-based confirmation via plain <a href> link (not form POST) per no-JS design. Matches existing unsubscribe UX pattern.
  • Email link carries &confirm=yes — Phase 88 decision: one-tap UX for legitimate email clicks. Gate protects against direct URL manipulation and iframe rendering, not prefetch (which is pre-authorized).
  • MessageVerifier TTL, not JWT — Phase 88 decision: Rails' built-in message_verifier with 30-day TTL for simplicity and Clerk-JWT-free implementation.

Feature flag & rollout:

  • Cohort gate as ENV-controlled predicate — Phase 88 decision: DIGEST_ROLLOUT_DENOMINATOR (0 = off, 1 = 100%, 10 = 10%) provides operator control without code deployment. Allowlist (email-based) short-circuits for early access.
  • weekly_digest_settings PostHog flag for frontend visibility — Phase 86 decision: default-gate the Settings section until feature is ready. Phase 89: retire the flag when rollout is complete (flag-gated in PostHog, not code).
  • 6-day idempotency guard (not 7) — Phase 87 decision: allows timezone/scheduling edge cases without violating the one-per-week contract. Analytics label uses 7-day "week" semantics.

Data & observability:

  • Denormalized last_digest_sent_at on UserDetail — Phase 87 decision: write timestamp after send to avoid querying mailer logs. Enables idempotency guard + GraphQL exposure.
  • PostHog user property sync after SYNC_USER — Phase 88 decision: frontend calls syncDigestPropertiesToPosthog() after every user sync mutation. Ensures PostHog always has current state for cohort analysis.
  • AI prompt VERSION field for tuning tracking — Phase 89 decision: bump VERSION constant (1 → 2) and include it in fallback event properties. Lets PostHog track which prompt version generated each insight.

Requirements Coverage

All 32 requirements from the v1.22 milestone roadmap satisfied across 4 phases.

Phase 86: 8 tasks

  • Settings component, UI flow, email templates, unsubscribe page layout

Phase 87: 8 tasks

  • WeeklyDigestEnqueueJob (Crono trigger, timezone matching), WeeklyDigestSendJob (idempotency, send), WeeklyDigest::AssembleDigestData (payload assembly, AI prompt v1), AI fallback pool, PostHog events (telemetry surface)

Phase 88: 8 tasks

  • 30-day TTL verifiers, confirmation-gate logic, Email::ClickRedirectsController (click tracking), GraphQL properties + PostHog sync, cohort denominator gate, smoke spec

Phase 89: 8 tasks

  • Full rollout (denominator flip), flag retirement, Penny content drop, prompt v2 tuning, user-facing CHANGELOG, engineering CHANGELOG catch-up, north-star tick, milestone audit

Outcomes

For users: Weekly digest unsubscribe is now a confirmed action (protects against accidents), email links work in one tap, and they can re-enable digest from Settings at any time.

For operators: Cohort-based rollout (DIGEST_ROLLOUT_DENOMINATOR) allows staged deployment (1% → 10% → 100%) and instant emergency off (set to 0). PostHog events (weekly_digest_clicked, weekly_digest_suppressed, cohort_excluded) measure adoption and troubleshoot production issues.

For the product: Digest delivery is now complete and observable. Activation signals (digest opt-in, retention) feed the North Star metric and inform feature investment.

Tech Debt

Closed:

  • ✅ Stub isolation bug in cohort gate tests (Dave diagnosed, Codi fixed via instance_double + and_yield)
  • ✅ Verifier TTL token encoding/decoding mismatch (resolved via confirm=yes parameter append)
  • ✅ Time-travel leakage from TTL spec into GDPR specs (wrapped travel_to in block form)

Deferred (non-blocking):

  • Cypress smoke spec 14-weekly-digest-unsubscribe.cy.ts requires post-deploy staging run (operator task for Vicki)
  • DIGEST_ROLLOUT_DENOMINATOR=10 Cloud Run environment variable must be set before first Crono tick post-deploy (operator task for Vicki) — Update (OBJ-2982, 2026-08-29): this operator step was never actually completed in either environment, and — per the correction note at the top of this page — a Phase-87 guard would have voided the cohort gate even if it had been. OBJ-2982 wired DIGEST_ROLLOUT_DENOMINATOR directly into the deploy manifests instead: deploy/crono.staging.yaml (1) and deploy/crono.production.yaml (10).
  • c57611e6 — Phase 88: Main implementation (TTL, confirm-gate, click-redirect, cohort-gate, PostHog sync, smoke spec, docs)
  • 795d556f — RuboCop autocorrect (IfUnlessModifier, FirstArgumentIndentation)
  • 1489ab76 — Test isolation fix (replace leaking User.includes stub with instance_double + and_yield)
  • 282b082b — UI-SPEC reconciliation (GET vs POST, confirm-gate rationale clarification)
  • 4ce9326c — Squash merge to master (PR #744 merged)

Last updated: 2026-08-29

Loading…