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_DENOMINATORcohort 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?inweekly_digest_enqueue_job.rb) predated Phase 88's cohort-gate design and was never reconciled with it — an emptyWEEKLY_DIGEST_INTERNAL_ALLOWLISTaborted 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 readsreturn 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:
MessageVerifierwith 30-day TTL on unsubscribe tokens; backward compatibility for pre-TTL tokensUnsubscribesController#showconfirm-gate logic;:awaiting_confirmation,:success,:resubscribed,:expiredstatesEmail::ClickRedirectsControllerwith signed token decoding, whitelist enforcement (app.objectuve.com,objectuve.com), andweekly_digest_clickedPostHog capture/unsubscribe_url_for()mailer helper appends&confirm=yesfor one-tap UX (email links bypass confirmation page)/smoke/unsubscribe_tokenstaging-only endpoint (non-production guard) for Cypress integrationSocial::WeeklyDigestEnqueueJobcohort gate (cohort_in_rollout?predicate);DIGEST_ROLLOUT_DENOMINATORENV parsing (0 = emergency off, 1 = 100%, 10 = 10%); allowlist bypassweekly_digest_suppressedPostHog event withreason: 'cohort_excluded'when user is outside cohorthas_digest_enabled+last_digest_sent_atGraphQL fields on UserType (with nil=true default forhas_digest_enabled)- ≥80% line / 75% branch coverage on all new/modified Rails files
Frontend:
hasDigestEnabled+lastDigestSentAtin GraphQLSYNC_USER+GET_USERqueriessyncDigestPropertiesToPosthog()helper inuserToPosthog.ts(maps GraphQL fields to PostHogidentify()call)- Cypress smoke spec
14-weekly-digest-unsubscribe.cy.tscovering full flow: confirm page, one-tap unsubscribe, GraphQL mutation, cleanup - Node task in
cypress.smoke.config.tsfor 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_confirmationstate with UI copy, confirmation page layout, and CTA flow?confirm=yesparameter design (one-tap UX for email links vs. direct navigation)- Staging-only
/smoke/unsubscribe_tokenhelper 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=yesgate rationale (direct URL manipulation + iframe rendering, not bot-prefetch)
- CHANGELOG.md v3.9.172 entry (GitHub Releases format)
Phases
| Phase | Name | Status | PR | Tag | Deliverable |
|---|---|---|---|---|---|
| 86 | Weekly Digest Foundation | Shipped | #732 | v3.9.168 | Settings component, email templates, unsubscribe page |
| 87 | Weekly Digest Send Loop | Shipped | #741 | v3.9.171 | Crono + Sidekiq jobs, AI insight assembly, PostHog telemetry |
| 88 | Weekly Digest Delivery — Complete | Shipped | #744 | v3.9.172 | Token TTL, confirm-gate, click redirect, GraphQL properties, cohort gate, smoke spec |
| 89 | Weekly Digest Polish + Full Rollout | Shipped | #749 | v3.9.178 | Flag 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
WeeklyDigestEnqueueJobevery 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_verifierwith 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_settingsPostHog 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_aton 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_toin block form)
Deferred (non-blocking):
- Cypress smoke spec
14-weekly-digest-unsubscribe.cy.tsrequires post-deploy staging run (operator task for Vicki) DIGEST_ROLLOUT_DENOMINATOR=10Cloud 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 wiredDIGEST_ROLLOUT_DENOMINATORdirectly into the deploy manifests instead:deploy/crono.staging.yaml(1) anddeploy/crono.production.yaml(10).
Related Artifacts
- Roadmap: v1.22-weekly-digest-email-ROADMAP.md
- Feature docs: docs/features/weekly-digest-email.md
- Architecture docs: docs/architecture/email.md
- Phase 86 PLAN: Phase 86 (foundation) — not yet documented in planning directory
- Phase 87 PLAN: Phase 87 (send loop) — not yet documented in planning directory
- Phase 88 PLAN: 88-88-PLAN.md
- Phase 89 PLAN: Phase 89 (rollout + tuning) — not yet documented in planning directory
- UI-SPEC: 88-weekly-digest-delivery/UI-SPEC.md (Phases 86–88 visual spec; Phase 89 has no new UI)
- Merge PRs:
Related Commits
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