Uh oh!
There was an error while loading. Please reload this page.
fix one-click unsub from the header - #195
Conversation
WalkthroughAdds a new POST API route for one-click unsubscribe and updates the campaign service to generate and queue a one-click unsubscribe URL (used in outgoing emails); the route reads id and hash from the request query, calls unsubscribe logic, logs outcomes, and returns JSON. Changes
Sequence Diagram(s)sequenceDiagram
participant CampaignService
participant EmailQueueService
participant Recipient
participant EmailClient
participant UnsubAPI
participant UnsubService
CampaignService->>CampaignService: createOneClickUnsubUrl(contactId, campaignId)
CampaignService->>EmailQueueService: queueEmail(..., oneClickUnsubUrl)
EmailQueueService-->>Recipient: Send email (contains one-click URL)
Recipient->>EmailClient: Click unsubscribe link
EmailClient->>UnsubAPI: POST /api/unsubscribe-oneclick?id=...&hash=...
UnsubAPI->>UnsubAPI: Validate presence of id and hash
UnsubAPI->>UnsubService: unsubscribeContactFromLink(id, hash)
UnsubService-->>UnsubAPI: Result (contact)
UnsubAPI-->>EmailClient: 200 JSON { success: true, message: "Successfully unsubscribed" } or 500 on error
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. ✨ Finishing Touches
🧪 Generate unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File ( |
There was a problem hiding this comment.
Actionable comments posted: 2
🔭 Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)
396-402: Ensure EmailQueueService addsList-UnsubscribeandList-Unsubscribe-PostheadersIt appears that
EmailQueueService.queueEmaildoes not currently set either unsubscribe header. To support one-click unsubscribe per RFC 8058, please update the method in
apps/web/src/server/service/email-queue-service.ts
to include the following when a one-click URL is provided:
List-Unsubscribe: <oneClickUnsubUrl>List-Unsubscribe-Post: List-Unsubscribe=One-ClickThis change is required so Gmail and other major providers will recognize and honor the one-click unsubscribe link.
🧹 Nitpick comments (4)
apps/web/src/server/service/campaign-service.ts (1)
102-110: Add explicit return type and consider centralizing hash generationMinor tidy-ups:
- Add an explicit return type (
string) to exported functions for clarity.- The hash derivation is duplicated across helpers. Consider extracting a small utility (e.g.,
signUnsubId(unsubId: string): string) to DRY this up and avoid inconsistencies later.-export function createOneClickUnsubUrl(contactId: string, campaignId: string) {+export function createOneClickUnsubUrl(contactId: string, campaignId: string): string {A small utility (outside this range) to reduce duplication:
// e.g. near other helpersfunctionsignUnsubId(unsubId: string): string{returncreateHash("sha256").update(`${unsubId}-${env.NEXTAUTH_SECRET}`).digest("hex");}apps/web/src/app/api/unsubscribe-oneclick/route.ts (3)
38-41: Avoid splitting the composite id by '-' (ids may contain hyphens); log the unsubId insteadBoth contact and campaign IDs can contain hyphens (e.g., UUIDs).
id.split("-")[1]is unreliable and may produce incorrect campaign IDs. Prefer loggingunsubId: id, or change the id scheme altogether (see next comment).- logger.info(- { contactId: contact.id, campaignId: id.split("-")[1] },- "One-click unsubscribe successful"- );+ logger.info(+ { contactId: contact.id, unsubId: id },+ "One-click unsubscribe successful"+ );
52-56: Use consistent error logging shapeElsewhere we log errors as
{ err: error }for better stack/serialization. Keep it consistent.- logger.error(- { error: error instanceof Error ? error.message : error },- "One-click unsubscribe failed"- );+ logger.error(+ { err: error },+ "One-click unsubscribe failed"+ );
1-64: Optional: Add GET support as a fallback for clients that don’t POSTSome clients or link scanners might attempt a GET. While RFC 8058 specifies POST with
List-Unsubscribe-Post, adding a no-op GET that returns 200 can improve deliverability UX. If you do, still require a valididandhashand skip the header requirement for GET.I can draft a safe GET handler mirroring the POST logic (without the header check) if you’d like.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
apps/web/src/app/api/unsubscribe-oneclick/route.ts(1 hunks)apps/web/src/server/service/campaign-service.ts(3 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}
📄 CodeRabbit Inference Engine (CLAUDE.md)
Use Prettier with the Tailwind plugin for code formatting
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.tsapps/web/src/server/service/campaign-service.ts
{apps,packages}/**/*.{js,jsx,ts,tsx}
📄 CodeRabbit Inference Engine (CLAUDE.md)
{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.tsapps/web/src/server/service/campaign-service.ts
{apps,packages}/**/*.{ts,tsx}
📄 CodeRabbit Inference Engine (CLAUDE.md)
{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoidany, and use Zod for validation
Follow Vercel style guides with strict TypeScript
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.tsapps/web/src/server/service/campaign-service.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit Inference Engine (CLAUDE.md)
Use tRPC for internal API endpoints
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.tsapps/web/src/server/service/campaign-service.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit Inference Engine (.cursor/rules/general.mdc)
Include all required imports, and ensure proper naming of key components.
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.tsapps/web/src/server/service/campaign-service.ts
🔇 Additional comments (3)
apps/web/src/server/service/campaign-service.ts (2)
102-110: Good addition; mirror logic matches existing unsubscribe schemeThe function correctly mirrors
createUnsubUrlwith consistent hashing and URL construction for the one-click route.
266-267: Intentionally using one-click URL for headers while keeping the body link as standard unsubscribe looks goodIt’s a good split: render the standard unsubscribe link in the email body while providing the one-click URL for header-based unsubscribes.
apps/web/src/app/api/unsubscribe-oneclick/route.ts (1)
1-64: No delimiter ambiguity: IDs use cuid()Prisma schema confirms both
Contact.idandCampaign.idareString @default(cuid()), and CUIDs never include hyphens. Splitting on"-"is therefore safe and unambiguous. No changes required.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
apps/web/src/app/api/unsubscribe-oneclick/route.ts (1)
21-23: Missing RFC 8058 verification: validate List-Unsubscribe-Post header before processing.
Per RFC 8058, one-click unsubscribes are POSTs with headerList-Unsubscribe-Post: List-Unsubscribe=One-Click. Validate this header (case-insensitive), and optionally fall back to checking form-data if the header is absent.Apply this diff right before calling
unsubscribeContactFromLink:- // Process the unsubscribe using existing logic+ // Verify this is a one-click unsubscribe request (RFC 8058)+ const lupHeader = request.headers.get("list-unsubscribe-post");+ let isOneClick = lupHeader+ ? /list-unsubscribe\s*=\s*one-click/i.test(lupHeader)+ : false;+ if (!isOneClick) {+ // Optional fallback: some providers may POST the flag as form-data+ try {+ const fd = await request.formData();+ const luPost =+ (fd.get("List-Unsubscribe-Post") ?? fd.get("List-Unsubscribe"))?.toString() ?? "";+ isOneClick = /list-unsubscribe\s*=\s*one-click/i.test(luPost);+ } catch {+ // ignore body parsing errors+ }+ }+ if (!isOneClick) {+ return NextResponse.json(+ { error: "Invalid unsubscribe request" },+ { status: 400 }+ );+ }++ // Process the unsubscribe using existing logic const contact = await unsubscribeContactFromLink(id, hash);
🧹 Nitpick comments (4)
apps/web/src/app/api/unsubscribe-oneclick/route.ts (4)
7-10: Prefer request.nextUrl for parsing query params (edge-compatible and simpler).
Minor ergonomics/readability improvement and avoids constructing a new URL.Apply this diff:
- const url = new URL(request.url);- const id = url.searchParams.get("id");- const hash = url.searchParams.get("hash");+ const { searchParams } = request.nextUrl;+ const id = searchParams.get("id");+ const hash = searchParams.get("hash");
24-27: Minor: derive campaignId once to avoid repeated split and improve clarity.
This keeps logging consistent and avoids repeated string ops.Apply this diff:
- logger.info(- { contactId: contact.id, campaignId: id.split("-")[1] },- "One-click unsubscribe successful"- );+ const [, campaignId] = id.split("-");+ logger.info(+ { contactId: contact.id, campaignId },+ "One-click unsubscribe successful"+ );
37-47: Return 400 for invalid/tampered unsubscribe links instead of 500.unsubscribeContactFromLinkthrows for invalid links/hashes. Map those to a client error to avoid false 500s.Apply this diff:
- logger.error(- { error: error instanceof Error ? error.message : error },- "One-click unsubscribe failed"- );-- // Return error response- return NextResponse.json(- { error: "Failed to process unsubscribe request" },- { status: 500 }- );+ const message = error instanceof Error ? error.message : String(error);+ if (message === "Invalid unsubscribe link") {+ logger.warn({ error: message }, "One-click unsubscribe rejected");+ return NextResponse.json(+ { error: "Invalid unsubscribe link" },+ { status: 400 }+ );+ }++ logger.error({ error: message }, "One-click unsubscribe failed");+ return NextResponse.json(+ { error: "Failed to process unsubscribe request" },+ { status: 500 }+ );
5-49: Optional: validate query params with zod to enforce shape and fail fast.
Strong typing + validation will catch malformed ids (e.g., missing hyphen) before calling the service.Example addition (outside diff context):
import{z}from"zod";constUnsubQuerySchema=z.object({id: z.string().regex(/^[^-]+-[^-]+$/),// contactId-campaignIdhash: z.string().regex(/^[a-f0-9]{64}$/i),// sha256 hex});// ...constparse=UnsubQuerySchema.safeParse({ id, hash });if(!parse.success){logger.warn({issues: parse.error.issues},"Invalid unsubscribe link (schema)");returnNextResponse.json({error: "Invalid unsubscribe link"},{status: 400});}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
apps/web/src/app/api/unsubscribe-oneclick/route.ts(1 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
{apps,packages}/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}
📄 CodeRabbit Inference Engine (CLAUDE.md)
Use Prettier with the Tailwind plugin for code formatting
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.ts
{apps,packages}/**/*.{js,jsx,ts,tsx}
📄 CodeRabbit Inference Engine (CLAUDE.md)
{apps,packages}/**/*.{js,jsx,ts,tsx}: Group imports by source (internal/external) and alphabetize them
Use camelCase for variables and functions, PascalCase for components and classes
Use try/catch with specific error types for error handling
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.ts
{apps,packages}/**/*.{ts,tsx}
📄 CodeRabbit Inference Engine (CLAUDE.md)
{apps,packages}/**/*.{ts,tsx}: Use strong typing in TypeScript, avoidany, and use Zod for validation
Follow Vercel style guides with strict TypeScript
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.ts
apps/web/**/*.{ts,tsx}
📄 CodeRabbit Inference Engine (CLAUDE.md)
Use tRPC for internal API endpoints
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.ts
**/*.{js,jsx,ts,tsx}
📄 CodeRabbit Inference Engine (.cursor/rules/general.mdc)
Include all required imports, and ensure proper naming of key components.
Files:
apps/web/src/app/api/unsubscribe-oneclick/route.ts
🧬 Code Graph Analysis (1)
apps/web/src/app/api/unsubscribe-oneclick/route.ts (2)
apps/web/src/server/logger/log.ts (1)
logger(31-63)apps/web/src/server/service/campaign-service.ts (1)
unsubscribeContactFromLink(112-133)
🔇 Additional comments (2)
apps/web/src/app/api/unsubscribe-oneclick/route.ts (2)
1-3: Imports look correct and scoped appropriately.NextRequest,NextResponse, the campaign service, and logger are the right dependencies for this handler.
29-36: Good: minimal success response without PII.
No email or sensitive identifiers are returned; this aligns with privacy best practices for one-click endpoints.
| if (!id || !hash) { | ||
| logger.warn( | ||
| `One-click unsubscribe: Missing id or hash id: ${id} hash: ${hash} url: ${request.url}` | ||
| ); | ||
| return NextResponse.json( | ||
| { error: "Invalid unsubscribe link" }, | ||
| { status: 400 } | ||
| ); | ||
| } |
There was a problem hiding this comment.
Avoid logging the full URL; it may leak the unsubscribe hash (secret) into logs.
The current warn log includes request.url, which can contain the hash query param. That’s sensitive and should not be persisted.
Apply this diff to log only presence flags and avoid the raw URL:
- logger.warn(- `One-click unsubscribe: Missing id or hash id: ${id} hash: ${hash} url: ${request.url}`- );+ logger.warn(+ { hasId: Boolean(id), hasHash: Boolean(hash) },+ "One-click unsubscribe: missing id or hash"+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if(!id||!hash){ | |
| logger.warn( | |
| `One-click unsubscribe: Missing id or hash id: ${id} hash: ${hash} url: ${request.url}` | |
| ); | |
| returnNextResponse.json( | |
| {error: "Invalid unsubscribe link"}, | |
| {status: 400} | |
| ); | |
| } | |
| if(!id||!hash){ | |
| logger.warn( | |
| {hasId: Boolean(id),hasHash: Boolean(hash)}, | |
| "One-click unsubscribe: missing id or hash" | |
| ); | |
| returnNextResponse.json( | |
| {error: "Invalid unsubscribe link"}, | |
| {status: 400} | |
| ); | |
| } |
Summary by CodeRabbit
New Features
Notes / Bug Fixes