feat: email capture on catalog valuation report - #1908
Conversation
Adds an 'Email me this report' capture to the public catalog report page. Anonymous viewers can send themselves the report link plus the headline valuation via a new unauthenticated route that validates with zod and sends through the chat repo's Resend path. The valuation stays ungated: the capture sits alongside the number, never in front of it, and also renders on the no-measurements state anonymous viewers currently see. Co-Authored-By: Claude Fable 5 <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.
|
|
Warning Review limit reached
Next review available in: 52 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (4)
📒 Files selected for processing (6)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dd0a20efe0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return ( | ||
| <div className="flex flex-col gap-4 max-w-3xl"> | ||
| <CatalogReportError error={measurementsQuery.error} /> | ||
| <CatalogReportEmailCapture catalogId={catalogId} /> |
There was a problem hiding this comment.
Don't offer anonymous users a link they still cannot view
When the viewer is anonymous, useCatalogMeasurements is disabled because it requires authenticated, so measurements stays undefined and this new fallback renders the email capture anyway. Submitting it sends only /catalogs/${catalogId} back to the user, so the recipient returns to the same auth-gated error state rather than seeing the report; this breaks the advertised anonymous "Email me this report" flow unless the report data is made public or the capture is hidden until a report can actually load.
Useful? React with 👍 / 👎.
| const sent = await sendCatalogReportEmail({ | ||
| email: validated.data.email, | ||
| catalogId, | ||
| headlineValue: validated.data.headline_value, |
There was a problem hiding this comment.
Derive the headline value server-side
This endpoint trusts headline_value from the request and forwards it into the email subject/body, so anyone can POST an arbitrary number for any catalog ID and send a report email showing a valuation that was never computed for that catalog. Since this is an unauthenticated route, the server should either fetch/compute the headline from catalog data or omit it rather than accepting the client-supplied value.
Useful? React with 👍 / 👎.
|
|
||
| const emailReportBodySchema = z.object({ | ||
| email: z.string().trim().email().max(320), | ||
| headline_value: z.number().finite().positive().optional(), |
There was a problem hiding this comment.
Allow zero-value reports through validation
For catalogs with measurements but zero total streams, computeCatalogValuation produces a central value of 0, and the client posts that as headline_value. This schema rejects 0 because .positive() requires a value greater than zero, so those valid $0 reports always fail to email even though the value is optional and already displayed on the page.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
5 issues found across 10 files
Confidence score: 2/5
- In
app/api/catalogs/[catalogId]/email-report/route.ts, the unauthenticated POST can trigger paid Resend sends without throttling, creating a concrete abuse/cost-exhaustion risk if scripted at scale — add auth or strong rate limiting/IP throttling (and ideally abuse controls like CAPTCHA) before relying on this endpoint. - In
app/api/catalogs/[catalogId]/email-report/route.ts, trusting client-providedheadline_valuelets callers send misleading valuation headlines to recipients, which can undermine report integrity — compute the headline from server-side catalog data (or ignore client input for that field). - In
lib/catalog/emailReport/validateEmailReportBody.ts,.positive()rejects0, so valid $0 catalogs will fail with 400 and block legitimate report sends — switch to a non-negative validation rule and cover it with a test case. - In
components/Catalog/report/CatalogReportContent.tsx, the no-measurements path can show email capture to anonymous viewers when measurements are undefined, leading to confusing submissions; and incomponents/Catalog/report/CatalogReportEmailCapture.tsx, the async success message may be missed by screen readers — gate rendering on a valid authenticated/report-ready state and addrole="status"/aria-live="polite"for feedback.
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="components/Catalog/report/CatalogReportEmailCapture.tsx">
<violation number="1" location="components/Catalog/report/CatalogReportEmailCapture.tsx:49">
P3: Screen-reader users may not be told that the report was sent because the asynchronously rendered success message has no status/live-region semantics. Adding `role="status"` (or `aria-live="polite"`) to the success message would announce the result.</violation>
</file>
<file name="app/api/catalogs/[catalogId]/email-report/route.ts">
<violation number="1" location="app/api/catalogs/[catalogId]/email-report/route.ts:16">
P1: This unauthenticated POST endpoint triggers a paid Resend API call on every successful request but has no rate limiting, IP throttling, or abuse prevention. An attacker could script thousands of email-send requests per minute from different IPs, incurring cost exposure and potentially using this as a spam relay. Consider adding at minimum an IP-based rate limiter (e.g., in-memory with a modest per-IP window, or via Vercel's edge middleware) before calling the send function. Since the PR's design deliberately keeps the endpoint unauthenticated for anonymous viewers, rate limiting is the primary defense against abuse.</violation>
<violation number="2" location="app/api/catalogs/[catalogId]/email-report/route.ts:40">
P2: Recipients can receive an arbitrary valuation presented as the catalog report’s headline because this client-controlled number is trusted at the API boundary. Deriving the value from server-side catalog data (or omitting it unless it can be verified against the catalog) would keep the emailed report consistent with the live report.</violation>
</file>
<file name="components/Catalog/report/CatalogReportContent.tsx">
<violation number="1" location="components/Catalog/report/CatalogReportContent.tsx:55">
P2: In the no-measurements branch, the email capture is now rendered even when the viewer is anonymous and `measurements` is undefined purely because the measurements query requires auth. Submitting the form here just emails the same `/catalogs/${catalogId}` link, so the recipient lands back on the same auth-gated error state instead of seeing a report. This undermines the anonymous "Email me this report" flow unless the report data can be made public for this state or the capture is hidden until a report can actually load.</violation>
</file>
<file name="lib/catalog/emailReport/validateEmailReportBody.ts">
<violation number="1" location="lib/catalog/emailReport/validateEmailReportBody.ts:5">
P2: `headline_value` validation uses `.positive()`, which rejects a value of `0`. Catalogs with zero total streams legitimately produce a central valuation of `$0`, so those requests will always fail with a 400 even though the value is optional and already shown on the page. Use `.nonnegative()` instead to allow zero-value reports through.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| * value they saw) via the chat repo's Resend path. The viewer's email is | ||
| * used for the send only, never logged. | ||
| */ | ||
| export async function POST( |
There was a problem hiding this comment.
P1: This unauthenticated POST endpoint triggers a paid Resend API call on every successful request but has no rate limiting, IP throttling, or abuse prevention. An attacker could script thousands of email-send requests per minute from different IPs, incurring cost exposure and potentially using this as a spam relay. Consider adding at minimum an IP-based rate limiter (e.g., in-memory with a modest per-IP window, or via Vercel's edge middleware) before calling the send function. Since the PR's design deliberately keeps the endpoint unauthenticated for anonymous viewers, rate limiting is the primary defense against abuse.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/catalogs/[catalogId]/email-report/route.ts, line 16:
<comment>This unauthenticated POST endpoint triggers a paid Resend API call on every successful request but has no rate limiting, IP throttling, or abuse prevention. An attacker could script thousands of email-send requests per minute from different IPs, incurring cost exposure and potentially using this as a spam relay. Consider adding at minimum an IP-based rate limiter (e.g., in-memory with a modest per-IP window, or via Vercel's edge middleware) before calling the send function. Since the PR's design deliberately keeps the endpoint unauthenticated for anonymous viewers, rate limiting is the primary defense against abuse.</comment>
<file context>
@@ -0,0 +1,52 @@
+ * value they saw) via the chat repo's Resend path. The viewer's email is
+ * used for the send only, never logged.
+ */
+export async function POST(
+ request: Request,
+ context: { params: Promise<{ catalogId: string }> },
</file context>
| const sent = await sendCatalogReportEmail({ | ||
| email: validated.data.email, | ||
| catalogId, | ||
| headlineValue: validated.data.headline_value, |
There was a problem hiding this comment.
P2: Recipients can receive an arbitrary valuation presented as the catalog report’s headline because this client-controlled number is trusted at the API boundary. Deriving the value from server-side catalog data (or omitting it unless it can be verified against the catalog) would keep the emailed report consistent with the live report.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/catalogs/[catalogId]/email-report/route.ts, line 40:
<comment>Recipients can receive an arbitrary valuation presented as the catalog report’s headline because this client-controlled number is trusted at the API boundary. Deriving the value from server-side catalog data (or omitting it unless it can be verified against the catalog) would keep the emailed report consistent with the live report.</comment>
<file context>
@@ -0,0 +1,52 @@
+ const sent = await sendCatalogReportEmail({
+ email: validated.data.email,
+ catalogId,
+ headlineValue: validated.data.headline_value,
+ });
+ if (!sent) {
</file context>
| return ( | ||
| <div className="flex flex-col gap-4 max-w-3xl"> | ||
| <CatalogReportError error={measurementsQuery.error} /> | ||
| <CatalogReportEmailCapture catalogId={catalogId} /> |
There was a problem hiding this comment.
P2: In the no-measurements branch, the email capture is now rendered even when the viewer is anonymous and measurements is undefined purely because the measurements query requires auth. Submitting the form here just emails the same /catalogs/${catalogId} link, so the recipient lands back on the same auth-gated error state instead of seeing a report. This undermines the anonymous "Email me this report" flow unless the report data can be made public for this state or the capture is hidden until a report can actually load.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Catalog/report/CatalogReportContent.tsx, line 55:
<comment>In the no-measurements branch, the email capture is now rendered even when the viewer is anonymous and `measurements` is undefined purely because the measurements query requires auth. Submitting the form here just emails the same `/catalogs/${catalogId}` link, so the recipient lands back on the same auth-gated error state instead of seeing a report. This undermines the anonymous "Email me this report" flow unless the report data can be made public for this state or the capture is hidden until a report can actually load.</comment>
<file context>
@@ -46,7 +47,14 @@ const CatalogReportContent = ({ catalogId }: CatalogReportContentProps) => {
+ return (
+ <div className="flex flex-col gap-4 max-w-3xl">
+ <CatalogReportError error={measurementsQuery.error} />
+ <CatalogReportEmailCapture catalogId={catalogId} />
+ </div>
+ );
</file context>
|
|
||
| const emailReportBodySchema = z.object({ | ||
| email: z.string().trim().email().max(320), | ||
| headline_value: z.number().finite().positive().optional(), |
There was a problem hiding this comment.
P2: headline_value validation uses .positive(), which rejects a value of 0. Catalogs with zero total streams legitimately produce a central valuation of $0, so those requests will always fail with a 400 even though the value is optional and already shown on the page. Use .nonnegative() instead to allow zero-value reports through.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/catalog/emailReport/validateEmailReportBody.ts, line 5:
<comment>`headline_value` validation uses `.positive()`, which rejects a value of `0`. Catalogs with zero total streams legitimately produce a central valuation of `$0`, so those requests will always fail with a 400 even though the value is optional and already shown on the page. Use `.nonnegative()` instead to allow zero-value reports through.</comment>
<file context>
@@ -0,0 +1,23 @@
+
+const emailReportBodySchema = z.object({
+ email: z.string().trim().email().max(320),
+ headline_value: z.number().finite().positive().optional(),
+});
+
</file context>
| className="rounded-2xl bg-card p-6 sm:p-8 shadow-[0_0_0_1px_var(--border),0_2px_4px_rgba(0,0,0,0.04)]" | ||
| > | ||
| {status === "sent" ? ( | ||
| <p className="font-heading text-sm font-bold text-foreground"> |
There was a problem hiding this comment.
P3: Screen-reader users may not be told that the report was sent because the asynchronously rendered success message has no status/live-region semantics. Adding role="status" (or aria-live="polite") to the success message would announce the result.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/Catalog/report/CatalogReportEmailCapture.tsx, line 49:
<comment>Screen-reader users may not be told that the report was sent because the asynchronously rendered success message has no status/live-region semantics. Adding `role="status"` (or `aria-live="polite"`) to the success message would announce the result.</comment>
<file context>
@@ -0,0 +1,93 @@
+ className="rounded-2xl bg-card p-6 sm:p-8 shadow-[0_0_0_1px_var(--border),0_2px_4px_rgba(0,0,0,0.04)]"
+ >
+ {status === "sent" ? (
+ <p className="font-heading text-sm font-bold text-foreground">
+ Report sent. Check your inbox.
+ </p>
</file context>
What
Adds an "Email me this report" capture to the public catalog valuation report page (
/catalogs/[catalogId]), which previously captured nothing. Part of #1902 (item C3).components/Catalog/report/CatalogReportEmailCapture.tsx- email input + submit card in the report house style (shadow-as-border, achromatic). Success state replaces the form: "Report sent. Check your inbox."app/api/catalogs/[catalogId]/email-report/route.ts- new deliberately unauthenticated POST route so ANONYMOUS viewers can use it. Validates the catalog id (uuid) and body (zod: email + optionalheadline_value), then sends via the chat repo's existing Resend path (lib/email/sendEmailfromRECOUP_FROM_EMAIL). The viewer's email is used for the send only and is never logged.lib/catalog/emailReport/-validateEmailReportBody(zod),renderReportEmailHtml(headline value + report link button),sendCatalogReportEmail(subject carries the formatted value, e.g. "Your catalog valuation report: $1.4M"). One exported function per file.CatalogReportContent.tsx- mounts the capture directly under the valuation card (capture alongside the number, never in front - the valuation stays fully ungated per the recorded architecture decision), and also on the no-measurements state, since anonymous viewers currently land there (the measurements query requires Privy auth).Why an api-side endpoint was not used
Checked the api submodule first:
POST /api/emailsis account-scoped (x-api-key, recipients restricted to the account's own email without a card on file) and api#773's valuation-report email only sends to the owning account on valuation complete. Neither fits an anonymous viewer typing an arbitrary address, so this ships as a thin chat-side route on the chat repo's own Resend integration.How to test
/catalogs/<catalogId>(works logged out).agent@recoupable.devwith the headline value in the subject and a button back to the report.POST /api/catalogs/not-a-uuid/email-reportreturns 400; body{"email":"nope"}returns 400; malformed JSON returns 400; a Resend failure returns 502.Tests:
pnpm exec vitest run lib/catalog/emailReport "app/api/catalogs/[catalogId]/email-report"- 4 files, 20 tests passing (validation, HTML render, send wiring, route status codes).pnpm exec tsc --noEmitclean for all files in this PR (remaining errors are the pre-existinglib/emails/__tests__baseline).Preview verification to follow in a PR comment.
Part of #1902
🤖 Generated with Claude Code
Summary by cubic
Adds “Email me this report” to the public catalog valuation page so anonymous viewers can email themselves the report link and headline value. Supports #1902 (C3) by enabling ungated report sharing without login.
POST /api/catalogs/[catalogId]/email-reportwithzodvalidation (UUID catalog id, email, optionalheadline_value); sends via the repo’sResendpath fromRECOUP_FROM_EMAILand never logs the viewer’s email.Written for commit dd0a20e. Summary will update on new commits.