Skip to content

feat(newsletter): manage subscription preferences (consolidates #78 + #126)#244

Open
hugodemenez wants to merge 1 commit into
betafrom
cursor/newsletter-preferences-consolidated-2f28
Open

feat(newsletter): manage subscription preferences (consolidates #78 + #126)#244
hugodemenez wants to merge 1 commit into
betafrom
cursor/newsletter-preferences-consolidated-2f28

Conversation

@hugodemenez

Copy link
Copy Markdown
Owner

Summary

Consolidates the overlapping work from PR #78 and PR #126 (both targeting issue #75) into a single feature branch rebased on the latest beta.

Users can now manage newsletter subscription preferences instead of only unsubscribing entirely.

Problem

Issue #75: users could unsubscribe from emails but could not manage individual subscription types:

  • Monthly stats update
  • Deltalytix weekly updates
  • Renewals notification

PR #78 and PR #126 both attempted to solve this with divergent approaches (different field names, auth models, and incomplete cron enforcement). Neither was merge-ready against current beta.

Solution

Database

  • Extend Newsletter with weeklySummaryEnabled, monthlyStatsEnabled, and renewalNoticeEnabled (all default true)
  • Migration normalizes existing newsletter emails to lowercase and deduplicates case-variant rows

Preference management UI

  • /newsletter: functional preferences form (replaces "coming soon")
  • /dashboard/settings: same form for authenticated users

Toggles:

  1. Newsletter subscription (isActive)
  2. Deltalytix weekly updates
  3. Monthly stats update
  4. Renewals notification

API — GET/POST /api/email/preferences

Dual authentication:

Mode Access
Session Logged-in users manage their own email (dashboard settings)
Signed token Email link users via /newsletter?email=…&token=… (24h expiry)

Uses shared lib/prisma client and strict boolean validation on POST.

Security — lib/newsletter-email.ts

Centralized HMAC-SHA256 signing for:

  • Unsubscribe tokens (no expiry) — replaces unsigned ?email= links
  • Preferences tokens (24h expiry) — for email-based preference management

Secret: NEWSLETTER_UNSUBSCRIBE_SECRET (falls back to CRON_SECRET; dev fallback in non-production).

Cron / email enforcement

  • Weekly summary cron (/api/cron): only sends to subscribers with isActive + weeklySummaryEnabled
  • Renewal notice cron: respects isActive + renewalNoticeEnabled
  • Weekly summary data loader: rejects users with weekly summaries disabled
  • Unsubscribe: disables all preference flags, not just isActive

All email templates and admin newsletter sends now use signed unsubscribe URLs.

Supersedes

This PR replaces and closes:

Testing

  • bun run typecheck
  • OPENAI_API_KEY=dummy bun run build
  • Manual: authenticated user saves preferences from /dashboard/settings
  • Manual: token link opens /newsletter?email=…&token=… and persists toggles
  • Manual: unsubscribe link with signed token disables all flags

Migration notes

Run bunx prisma db push or apply migration 20260624120000_newsletter_preferences before deploy.

Ensure NEWSLETTER_UNSUBSCRIBE_SECRET (or CRON_SECRET) is set in production so signed links work consistently across instances.

Closes #75

Open in Web Open in Cursor 

Replace overlapping PR #78 and PR #126 work with a single implementation
rebased on current beta:

- Add Newsletter preference fields (weekly, monthly stats, renewals)
- Dual-auth preferences API (Supabase session or signed email token)
- Secure HMAC unsubscribe links across email templates and cron jobs
- Enforce preferences in weekly summary and renewal notice crons
- Shared preferences UI on /newsletter and dashboard settings
- Email normalization migration for existing Newsletter rows

Closes #75

Co-authored-by: Hugo Demenez <hugodemenez@users.noreply.github.com>
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
deltalytix Ready Ready Preview, Comment Jun 24, 2026 9:39pm

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Automated Code Review

PR Review: Newsletter Preferences

This PR adds granular newsletter preferences (weekly summary, monthly stats, renewal notices), HMAC-signed unsubscribe/preferences links, a preferences API + UI, and email normalization. Overall direction is solid and it fixes a real security hole in the old unsubscribe flow.


Critical / High Priority

1. Welcome re-subscribe leaves preference flags disabled

welcome/route.ts upserts with update: { isActive: true } only. If a user previously unsubscribed (all flags set to false), signing up again sets isActive: true but leaves weeklySummaryEnabled, monthlyStatsEnabled, and renewalNoticeEnabled as false. They appear subscribed but receive no emails.

2. monthlyStatsEnabled is not enforced anywhere

The preference is exposed in the UI and stored in the DB, but no cron or sender checks it. Users can disable monthly stats with no effect — misleading UX and a compliance risk if this is marketed as a real opt-out.

3. Production secret fallback

getNewsletterSigningSecret() falls back to CRON_SECRET. Sharing signing material across cron auth and email tokens widens blast radius if one leaks. Prefer a dedicated NEWSLETTER_UNSUBSCRIBE_SECRET and fail hard in production if it’s missing (the throw is there, but the fallback weakens it).

4. Migration is destructive and non-transactional

The migration deletes all Newsletter rows, reinserts deduplicated data, then alters the table. If it fails mid-flight, data can be lost. BOOL_AND("isActive") also means duplicate emails merge to inactive if any duplicate was inactive — worth confirming that’s intended.


Security (mostly improved)

Good:

  • Unsubscribe no longer works with a bare ?email= param; HMAC tokens or authenticated session are required.
  • timingSafeEqual for signature verification.
  • Separate token purposes (unsubscribe vs preferences).
  • Preferences tokens expire after 24h.
  • Session users cannot modify another user’s email.

Notes:

  • Unsubscribe tokens never expire. A leaked link from an old email can unsubscribe permanently. Common pattern, but consider TTL or rotation if threat model requires it.
  • Dev hardcoded secret is fine locally; ensure it never ships to production.

Logic / Consistency

Renewal notices vs weekly summary behave differently:

  • Weekly cron: requires a Newsletter row with isActive + weeklySummaryEnabled.
  • Renewal cron: skips only when a Newsletter row exists and is inactive or renewalNoticeEnabled is false. Users with no row still get renewal emails.

If renewal notices are product/transactional, that may be fine. If they’re marketing, this is inconsistent with weekly summary and with the preferences UI implying user control.

createNewsletterPreferencesUrl is defined but unused. Emails still only link to unsubscribe, not preferences management — incomplete feature wiring.


Code Quality / React / TypeScript

Good:

  • Shared lib/newsletter-email.ts centralizes normalization, tokens, and defaults.
  • Reuse of NewsletterPreferencesForm in landing page and dashboard settings.
  • AbortController in the form’s fetch effect.
  • Consistent move to shared prisma client in several routes.

Minor issues:

  • Unused import createNewsletterUnsubscribeUrl in admin/actions/newsletter.ts.
  • useEffect depends on individual copy.* strings; parent passes a new copy object each render. Works today because strings are stable, but destructuring copy in the parent or memoizing would be cleaner.
  • Record<string, unknown> for POST body instead of a typed payload.
  • findFirst + update/create instead of upsert — small race under concurrent saves.

Error Handling

Good: API routes return appropriate 400/401/403/500; JSON parse errors handled on POST.

Gaps:

  • Unsubscribe redirect on error returns JSON instead of a user-facing page (GET path).
  • No rate limiting on token verification endpoints (minor brute-force concern; HMAC makes forgery hard).
  • After unsubscribe redirect (/newsletter?status=unsubscribed&email=...), no token is provided, so the preferences form correctly blocks access — but users can’t easily re-manage preferences without signing in.

Performance

  • Renewal cron batches preference lookup with one findMany — good.
  • Email normalization + dedup in admin import — good.
  • No major performance concerns.

Summary

Area Verdict
Security Major improvement (signed unsubscribe); minor secret-sharing concern
Feature completeness Weekly + renewal wired; monthly stats is a stub
Data migration Risky; needs careful deploy plan
React/TS Clean, reusable form; minor nits
Error handling Adequate for APIs; edge cases on re-subscribe

Recommendation: Fix the welcome upsert to reset preference flags (or use explicit defaults on re-subscribe), wire monthlyStatsEnabled into its sender or hide the toggle until implemented, and document/deploy the migration carefully. The HMAC unsubscribe change is a clear win and should ship with a dedicated production secret configured.

Blockers before merge: Items 1 and 2 above if monthly stats and re-subscribe flows are in scope for this release.


Generated by Cursor CLI workflow — 1bbdf7a076f549e5f2c1305a11f7cc7e741d4cf3 feat(newsletter): consolidate preference management for issue #75 · 2 review(s) remaining

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot did not run on this PR, so there is no automated review signal to approve against. Human review is recommended before merge given the signed-token auth and newsletter preference API changes. No additional reviewers were assigned (the PR author is the sole repository maintainer).

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Approver

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed PR #244 against beta; base is already beta, so no rebase was needed.

Requesting changes before merge. The signed-unsubscribe direction is good, but the current PR still leaves some sent unsubscribe links unsigned, re-subscribe can produce an active row with every delivery flag disabled, and monthlyStatsEnabled is exposed without any enforcement path.

Per the daily-review instructions, I also spun up a repair agent. It pushed repair/pr-244-newsletter-preferences at aec3132b75fb28a1d263df815e6621d0d52392b2; bun run typecheck and focused lint passed there. That repair branch fixes the re-subscribe and signed-unsubscribe gaps it found, but monthly stats remains unresolved because no existing monthly-stats sender was present to wire up.

Open in Web View Automation 

Sent by Cursor Automation: Coworker

await prisma.newsletter.upsert({
where: { email: record.email },
where: { email },
update: { isActive: true },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-subscribing only sets isActive back to true. If this address previously used the new unsubscribe flow, the row also has weeklySummaryEnabled, monthlyStatsEnabled, and renewalNoticeEnabled set to false, so a new signup becomes active but still receives no mail. Please reset the granular flags to their defaults in this upsert update.

import { parse } from "csv-parse"
import { render } from "@react-email/render"
import {
createNewsletterUnsubscribeUrl,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is imported, but the real send paths below still build bare /api/email/unsubscribe?email=... links. With the new unsubscribe route requiring a signed token or logged-in session, those emailed links will fail for normal recipients; the bulk/test sends should use this helper too.

<>
{renderToggle("newsletter-status", copy.statusLabel, "isActive")}
{renderToggle("weekly-summary", copy.weeklySummaryLabel, "weeklySummaryEnabled")}
{renderToggle("monthly-stats", copy.monthlyStatsLabel, "monthlyStatsEnabled")}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exposes monthlyStatsEnabled as a user-facing opt-out, but I could not find a monthly-stats sender or query that reads this flag. Until it is enforced (or hidden until the sender exists), users can disable this preference with no effect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants