Skip to content

Filter bulk unsubscribe to suggested senders - #3134

Open
elie222 wants to merge 4 commits into
mainfrom
filter-suggested-unsubscribes
Open

Filter bulk unsubscribe to suggested senders#3134
elie222 wants to merge 4 commits into
mainfrom
filter-suggested-unsubscribes

Conversation

@elie222

@elie222 elie222 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Browser QA passed · QA report

Apply rerun-qa to rerun.

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

  • Suggested filter. New first option in the filter dropdown showing only suggested senders, with a line explaining the rule. Replaces the select-on-load behaviour.
  • Suggestions computed server-side. The API takes a suggested flag, pushes the volume and read-rate rules into the SQL HAVING clause, drops the row limit, and runs the same shared ranking function the email uses. Both surfaces now derive from identical input.
  • Tighter heuristic. Higher minimum email count, lower read-rate cutoff, ranked by unread volume rather than total volume, and capped so a long list doesn't become unactionable.
  • Filter reflected in the URL, so a view survives a refresh and can be shared. The legacy select param 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

  • Unit tests updated and extended for the new thresholds, the unread-based ranking, and the cap
  • Query change smoke-tested against a local Postgres to confirm the new HAVING clause binds and filters correctly
  • Lint clean on all changed files

🤖 Generated with Claude Code

Review in cubic

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>
@cursor

cursor Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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.

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
inbox-zero Ignored Ignored Preview Aug 5, 2026 3:23pm

@cubic-dev-ai cubic-dev-ai 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.

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 } : {}),

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.

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>(() => {

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.

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,

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.

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>

elie222 and others added 2 commits August 3, 2026 02:02
* 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>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ elie222
❌ gentlemandev
You have signed the CLA already but the status is still pending? Let us recheck it.

@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@elie222 elie222 added the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
@gentlemandev gentlemandev removed the rerun-qa Run isolated browser QA once on the current commit label Aug 5, 2026
Repository owner deleted a comment from gentlemandev Aug 5, 2026
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.

3 participants