Filter bulk unsubscribe to suggested senders - #3134
Conversation
Replace the select-on-load deep link with a Suggested filter that shows only the senders worth unsubscribing from, so the list is readable instead of a partially checked table. Suggestions are now computed server-side over the full sender set rather than over whichever rows the page happened to have loaded, so the count in the periodic email matches the list the link lands on. Also tighten the heuristic: require more emails before a low read rate counts as signal, lower the read-rate cutoff, rank by unread volume instead of total volume, and cap the list so it stays actionable. The filter is reflected in the URL so views survive a refresh and can be shared. The legacy select param keeps working for links already sent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
There was a problem hiding this comment.
3 issues found across 11 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx">
<violation number="1" location="apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx:194">
P2: The Suggested view can still disagree with the inbox-health email around month boundaries because the new server-side suggestion request uses a 90-day window while the email uses three calendar months. Align the page default and email window (for example, use `subMonths(now, 3)` for the page) so the shared ranking operates on identical input.</violation>
</file>
<file name="apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts">
<violation number="1" location="apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts:1144">
P2: The new URL-backed filter is only read once in the `useState` initializer and is never reconciled with later changes to `searchParams`. This defeats part of the stated goal of the change: once the user switches filters, the tab's filter state (`filtersArray` / the `suggested` flag) is the source of truth for the API call, but the URL becomes the source for Back/Forward navigation. If a user switches from 'Suggested' to 'Unhandled' and then presses the browser Back button, the URL shows `?filter=suggested` while the table still shows Unhandled rows (and vice-versa) — the view and the shareable/refreshable URL disagree. Consider deriving the filter from `searchParams` (or adding a `useEffect` that re-syncs `filter` when the relevant params change) so the UI tracks the URL, rather than initializing state once.</violation>
</file>
<file name="apps/web/app/api/user/stats/newsletters/route.ts">
<violation number="1" location="apps/web/app/api/user/stats/newsletters/route.ts:78">
P2: The suggested filter on the bulk-unsubscribe page and the inbox-health email are intended to produce one identical list (that's the stated goal of this PR), but the two still compute the new SQL HAVING predicate over different inputs. The page always sends `types` (the emails-to-include filter) and a date range into `getSenderEmailStats` (route.ts forces `limit: null` and adds `minEmails`/`maxReadRate`), while the inbox-health email runs the same predicate over a fixed 3-month window with no type filter. So the SQL read-rate/min-emails computation on the page is narrowed by whichever emails-to-include option and date range the user has selected, which changes the qualifying senders and the count. As a result, when a user's include-filter or date range differs from the email's, the email's `suggestionCount` and the page's suggested-row count can still diverge — the parity the change is meant to deliver isn't guaranteed. Consider aligning the two to a single shared window/filter set (or explicitly re-deriving the page's suggested eligibility outside the page's types/date filters) so the email count and page list stay in lockstep.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| orderDirection: sortDirection, | ||
| limit: expanded ? 500 : 50, | ||
| includeMissingUnsubscribe: true, | ||
| ...(isSuggestedFilter ? { suggested: true } : {}), |
There was a problem hiding this comment.
P2: The Suggested view can still disagree with the inbox-health email around month boundaries because the new server-side suggestion request uses a 90-day window while the email uses three calendar months. Align the page default and email window (for example, use subMonths(now, 3) for the page) so the shared ranking operates on identical input.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx, line 194:
<comment>The Suggested view can still disagree with the inbox-health email around month boundaries because the new server-side suggestion request uses a 90-day window while the email uses three calendar months. Align the page default and email window (for example, use `subMonths(now, 3)` for the page) so the shared ranking operates on identical input.</comment>
<file context>
@@ -183,13 +182,16 @@ export function BulkUnsubscribe() {
orderDirection: sortDirection,
limit: expanded ? 500 : 50,
includeMissingUnsubscribe: true,
+ ...(isSuggestedFilter ? { suggested: true } : {}),
...getDateRangeParams(dateRange),
...(search ? { search } : {}),
</file context>
| const router = useRouter(); | ||
| const pathname = usePathname(); | ||
|
|
||
| const [filter, setFilterState] = useState<NewsletterFilterType>(() => { |
There was a problem hiding this comment.
P2: The new URL-backed filter is only read once in the useState initializer and is never reconciled with later changes to searchParams. This defeats part of the stated goal of the change: once the user switches filters, the tab's filter state (filtersArray / the suggested flag) is the source of truth for the API call, but the URL becomes the source for Back/Forward navigation. If a user switches from 'Suggested' to 'Unhandled' and then presses the browser Back button, the URL shows ?filter=suggested while the table still shows Unhandled rows (and vice-versa) — the view and the shareable/refreshable URL disagree. Consider deriving the filter from searchParams (or adding a useEffect that re-syncs filter when the relevant params change) so the UI tracks the URL, rather than initializing state once.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/hooks.ts, line 1144:
<comment>The new URL-backed filter is only read once in the `useState` initializer and is never reconciled with later changes to `searchParams`. This defeats part of the stated goal of the change: once the user switches filters, the tab's filter state (`filtersArray` / the `suggested` flag) is the source of truth for the API call, but the URL becomes the source for Back/Forward navigation. If a user switches from 'Suggested' to 'Unhandled' and then presses the browser Back button, the URL shows `?filter=suggested` while the table still shows Unhandled rows (and vice-versa) — the view and the shareable/refreshable URL disagree. Consider deriving the filter from `searchParams` (or adding a `useEffect` that re-syncs `filter` when the relevant params change) so the UI tracks the URL, rather than initializing state once.</comment>
<file context>
@@ -1123,8 +1125,50 @@ export function useBulkUnsubscribeShortcuts<T extends Row>({
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const [filter, setFilterState] = useState<NewsletterFilterType>(() => {
+ // `select=suggested` is the legacy form used by inbox health emails
+ // already sent out
</file context>
| limit: options.limit, | ||
| // Suggestions are ranked and capped below, so the row limit would | ||
| // otherwise decide which senders are eligible | ||
| limit: options.suggested ? null : options.limit, |
There was a problem hiding this comment.
P2: The suggested filter on the bulk-unsubscribe page and the inbox-health email are intended to produce one identical list (that's the stated goal of this PR), but the two still compute the new SQL HAVING predicate over different inputs. The page always sends types (the emails-to-include filter) and a date range into getSenderEmailStats (route.ts forces limit: null and adds minEmails/maxReadRate), while the inbox-health email runs the same predicate over a fixed 3-month window with no type filter. So the SQL read-rate/min-emails computation on the page is narrowed by whichever emails-to-include option and date range the user has selected, which changes the qualifying senders and the count. As a result, when a user's include-filter or date range differs from the email's, the email's suggestionCount and the page's suggested-row count can still diverge — the parity the change is meant to deliver isn't guaranteed. Consider aligning the two to a single shared window/filter set (or explicitly re-deriving the page's suggested eligibility outside the page's types/date filters) so the email count and page list stay in lockstep.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/api/user/stats/newsletters/route.ts, line 78:
<comment>The suggested filter on the bulk-unsubscribe page and the inbox-health email are intended to produce one identical list (that's the stated goal of this PR), but the two still compute the new SQL HAVING predicate over different inputs. The page always sends `types` (the emails-to-include filter) and a date range into `getSenderEmailStats` (route.ts forces `limit: null` and adds `minEmails`/`maxReadRate`), while the inbox-health email runs the same predicate over a fixed 3-month window with no type filter. So the SQL read-rate/min-emails computation on the page is narrowed by whichever emails-to-include option and date range the user has selected, which changes the qualifying senders and the count. As a result, when a user's include-filter or date range differs from the email's, the email's `suggestionCount` and the page's suggested-row count can still diverge — the parity the change is meant to deliver isn't guaranteed. Consider aligning the two to a single shared window/filter set (or explicitly re-deriving the page's suggested eligibility outside the page's types/date filters) so the email count and page list stay in lockstep.</comment>
<file context>
@@ -63,7 +73,13 @@ async function getEmailMessages(
- limit: options.limit,
+ // Suggestions are ranked and capped below, so the row limit would
+ // otherwise decide which senders are eligible
+ limit: options.suggested ? null : options.limit,
+ minEmails: options.suggested ? SUGGESTION_MIN_EMAILS : undefined,
+ maxReadRate: options.suggested
</file context>
* Redesign the periodic inbox health email Rebuild the rarely read senders email to the approved design: a card layout on a tinted page, a single primary action, and a ranked sender list where each row carries a bar sized by how much of that sender's mail goes unread. Rows now report an opened ratio instead of a read percentage, which reads faster and stays honest for senders never opened at all. The summary line also reports the weekly volume the user never opens, so the claim matches the underlying number rather than approximating it. The list heading and the trailing "more senders" line adapt to how many senders are listed, so the copy stays correct at the minimum threshold and when every suggestion already fits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Use the official mark and correct postal address in the email The header carried a lowercase wordmark that is not the official logo. Switch to the app icon plus the brand name, matching every other email in this package, and link it to the site like the others do. Correct the footer to the postal address that actually receives mail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Drop the redundant link on the email header icon The icon and the brand name both linked to the homepage, so assistive tech announced an unlabelled link followed by a labelled one to the same place. Leave the icon decorative and keep the brand name as the single labelled link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Use the text wordmark in the email header Drop the icon and set the brand name in caps to match the wordmark. The wordmark is pure letterforms, and its SVG cannot ship in email since most clients strip inline SVG, so text renders it faithfully and keeps working when a client blocks images. The markup keeps the mixed-case brand name and applies the caps as a transform, so the accessible name stays readable and a client that drops the transform still shows the brand name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
|
|
Problem
The periodic inbox health email deep-linked to the bulk unsubscribe page with
?select=suggested, which auto-checked the suggested rows in the full sender table. With dozens of rows it was hard to tell what was selected and what wasn't, and a stray click could silently clobber the selection.The counts could also disagree. The email computed suggestions over every sender in the window; the page recomputed the same predicate over whichever 50 rows it had loaded. So the email could say one number and the page show another.
Changes
suggestedflag, pushes the volume and read-rate rules into the SQLHAVINGclause, drops the row limit, and runs the same shared ranking function the email uses. Both surfaces now derive from identical input.selectparam still resolves to the filter for links already in inboxes.Rows keep their queried order, so the table's sort columns still work in the suggested view.
Testing
HAVINGclause binds and filters correctly🤖 Generated with Claude Code