fix one-click unsub from the header - #195

Merged
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe
Aug 16, 2025
Merged

fix one-click unsub from the header#195
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Aug 15, 2025

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Adds one-click unsubscribe support via a new API endpoint so recipients can instantly unsubscribe.
    • Emails now include a queued one-click unsubscribe URL, improving the unsubscribe flow.
    • Endpoint returns clear success/error responses for user actions.
  • Notes / Bug Fixes

    • Request validation only checks presence of id and hash (400 if missing); no List-Unsubscribe header validation.
    • Enhanced logging around unsubscribe events.

@coderabbitai

coderabbitaiBot commented Aug 15, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s)Summary
API: One-Click Unsubscribe Route
apps/web/src/app/api/unsubscribe-oneclick/route.ts
New Next.js POST handler: parses id and hash from URL query, returns 400 if missing, calls unsubscribeContactFromLink(id, hash), logs info/error, and returns 200 on success or 500 on failure. No List-Unsubscribe header validation performed.
Campaign Service Updates
apps/web/src/server/service/campaign-service.ts
Adds createOneClickUnsubUrl(contactId, campaignId) that builds id, hash, and /api/unsubscribe-oneclick URL; processContactEmail now computes oneClickUnsubUrl and passes it to EmailQueueService.queueEmail. Email body linkValues still use the original unsubscribeUrl.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops with code in tow,
I sew a link that's quick to go.
One click, one hop, the list grows thin,
A carrot cheer for quiet inbox wins. 🥕

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-click-unsubscribe

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 2

🔭 Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)

396-402: Ensure EmailQueueService adds List-Unsubscribe and List-Unsubscribe-Post headers

It appears that EmailQueueService.queueEmail does 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-Click

This 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 generation

Minor 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 instead

Both contact and campaign IDs can contain hyphens (e.g., UUIDs). id.split("-")[1] is unreliable and may produce incorrect campaign IDs. Prefer logging unsubId: 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 shape

Elsewhere 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 POST

Some 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 valid id and hash and 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.

📥 Commits

Reviewing files that changed from the base of the PR and between bb2a428 and d140b53.

📒 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.ts
  • apps/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.ts
  • apps/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, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/app/api/unsubscribe-oneclick/route.ts
  • apps/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.ts
  • apps/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.ts
  • apps/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 scheme

The function correctly mirrors createUnsubUrl with 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 good

It’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.id and Campaign.id are String @default(cuid()), and CUIDs never include hyphens. Splitting on "-" is therefore safe and unambiguous. No changes required.

Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts Outdated
Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts

@coderabbitaicoderabbitaiBot 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.

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 header List-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.
unsubscribeContactFromLink throws 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d140b53 and 9a7c9ab.

📒 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, avoid any, 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.

Comment on lines +11 to +19
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 }
);
}

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.

⚠️ Potential issue

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.

Suggested change
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}
);
}

@KMKoushik
KMKoushik merged commit 43d99bb into mainAug 16, 2025
2 checks passed
@KMKoushik
KMKoushik deleted the fix/one-click-unsubscribe branch August 16, 2025 23:09
@coderabbitaicoderabbitaiBot mentioned this pull request Sep 2, 2025
Sign up for freeto 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.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix one-click unsub from the header - #195

Merged
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe
Aug 16, 2025
Merged

fix one-click unsub from the header#195
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Aug 15, 2025

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Adds one-click unsubscribe support via a new API endpoint so recipients can instantly unsubscribe.
    • Emails now include a queued one-click unsubscribe URL, improving the unsubscribe flow.
    • Endpoint returns clear success/error responses for user actions.
  • Notes / Bug Fixes

    • Request validation only checks presence of id and hash (400 if missing); no List-Unsubscribe header validation.
    • Enhanced logging around unsubscribe events.

@coderabbitai

coderabbitaiBot commented Aug 15, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s)Summary
API: One-Click Unsubscribe Route
apps/web/src/app/api/unsubscribe-oneclick/route.ts
New Next.js POST handler: parses id and hash from URL query, returns 400 if missing, calls unsubscribeContactFromLink(id, hash), logs info/error, and returns 200 on success or 500 on failure. No List-Unsubscribe header validation performed.
Campaign Service Updates
apps/web/src/server/service/campaign-service.ts
Adds createOneClickUnsubUrl(contactId, campaignId) that builds id, hash, and /api/unsubscribe-oneclick URL; processContactEmail now computes oneClickUnsubUrl and passes it to EmailQueueService.queueEmail. Email body linkValues still use the original unsubscribeUrl.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops with code in tow,
I sew a link that's quick to go.
One click, one hop, the list grows thin,
A carrot cheer for quiet inbox wins. 🥕

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-click-unsubscribe

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 2

🔭 Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)

396-402: Ensure EmailQueueService adds List-Unsubscribe and List-Unsubscribe-Post headers

It appears that EmailQueueService.queueEmail does 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-Click

This 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 generation

Minor 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 instead

Both contact and campaign IDs can contain hyphens (e.g., UUIDs). id.split("-")[1] is unreliable and may produce incorrect campaign IDs. Prefer logging unsubId: 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 shape

Elsewhere 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 POST

Some 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 valid id and hash and 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.

📥 Commits

Reviewing files that changed from the base of the PR and between bb2a428 and d140b53.

📒 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.ts
  • apps/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.ts
  • apps/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, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/app/api/unsubscribe-oneclick/route.ts
  • apps/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.ts
  • apps/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.ts
  • apps/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 scheme

The function correctly mirrors createUnsubUrl with 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 good

It’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.id and Campaign.id are String @default(cuid()), and CUIDs never include hyphens. Splitting on "-" is therefore safe and unambiguous. No changes required.

Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts Outdated
Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts

@coderabbitaicoderabbitaiBot 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.

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 header List-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.
unsubscribeContactFromLink throws 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d140b53 and 9a7c9ab.

📒 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, avoid any, 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.

Comment on lines +11 to +19
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 }
);
}

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.

⚠️ Potential issue

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.

Suggested change
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}
);
}

@KMKoushik
KMKoushik merged commit 43d99bb into mainAug 16, 2025
2 checks passed
@KMKoushik
KMKoushik deleted the fix/one-click-unsubscribe branch August 16, 2025 23:09
@coderabbitaicoderabbitaiBot mentioned this pull request Sep 2, 2025
Sign up for freeto 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.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix one-click unsub from the header - #195

Merged
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe
Aug 16, 2025
Merged

fix one-click unsub from the header#195
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Aug 15, 2025

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Adds one-click unsubscribe support via a new API endpoint so recipients can instantly unsubscribe.
    • Emails now include a queued one-click unsubscribe URL, improving the unsubscribe flow.
    • Endpoint returns clear success/error responses for user actions.
  • Notes / Bug Fixes

    • Request validation only checks presence of id and hash (400 if missing); no List-Unsubscribe header validation.
    • Enhanced logging around unsubscribe events.

@coderabbitai

coderabbitaiBot commented Aug 15, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s)Summary
API: One-Click Unsubscribe Route
apps/web/src/app/api/unsubscribe-oneclick/route.ts
New Next.js POST handler: parses id and hash from URL query, returns 400 if missing, calls unsubscribeContactFromLink(id, hash), logs info/error, and returns 200 on success or 500 on failure. No List-Unsubscribe header validation performed.
Campaign Service Updates
apps/web/src/server/service/campaign-service.ts
Adds createOneClickUnsubUrl(contactId, campaignId) that builds id, hash, and /api/unsubscribe-oneclick URL; processContactEmail now computes oneClickUnsubUrl and passes it to EmailQueueService.queueEmail. Email body linkValues still use the original unsubscribeUrl.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops with code in tow,
I sew a link that's quick to go.
One click, one hop, the list grows thin,
A carrot cheer for quiet inbox wins. 🥕

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-click-unsubscribe

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 2

🔭 Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)

396-402: Ensure EmailQueueService adds List-Unsubscribe and List-Unsubscribe-Post headers

It appears that EmailQueueService.queueEmail does 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-Click

This 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 generation

Minor 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 instead

Both contact and campaign IDs can contain hyphens (e.g., UUIDs). id.split("-")[1] is unreliable and may produce incorrect campaign IDs. Prefer logging unsubId: 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 shape

Elsewhere 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 POST

Some 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 valid id and hash and 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.

📥 Commits

Reviewing files that changed from the base of the PR and between bb2a428 and d140b53.

📒 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.ts
  • apps/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.ts
  • apps/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, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/app/api/unsubscribe-oneclick/route.ts
  • apps/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.ts
  • apps/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.ts
  • apps/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 scheme

The function correctly mirrors createUnsubUrl with 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 good

It’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.id and Campaign.id are String @default(cuid()), and CUIDs never include hyphens. Splitting on "-" is therefore safe and unambiguous. No changes required.

Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts Outdated
Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts

@coderabbitaicoderabbitaiBot 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.

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 header List-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.
unsubscribeContactFromLink throws 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d140b53 and 9a7c9ab.

📒 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, avoid any, 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.

Comment on lines +11 to +19
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 }
);
}

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.

⚠️ Potential issue

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.

Suggested change
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}
);
}

@KMKoushik
KMKoushik merged commit 43d99bb into mainAug 16, 2025
2 checks passed
@KMKoushik
KMKoushik deleted the fix/one-click-unsubscribe branch August 16, 2025 23:09
@coderabbitaicoderabbitaiBot mentioned this pull request Sep 2, 2025
Sign up for freeto 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.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix one-click unsub from the header - #195

Merged
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe
Aug 16, 2025
Merged

fix one-click unsub from the header#195
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Aug 15, 2025

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Adds one-click unsubscribe support via a new API endpoint so recipients can instantly unsubscribe.
    • Emails now include a queued one-click unsubscribe URL, improving the unsubscribe flow.
    • Endpoint returns clear success/error responses for user actions.
  • Notes / Bug Fixes

    • Request validation only checks presence of id and hash (400 if missing); no List-Unsubscribe header validation.
    • Enhanced logging around unsubscribe events.

@coderabbitai

coderabbitaiBot commented Aug 15, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s)Summary
API: One-Click Unsubscribe Route
apps/web/src/app/api/unsubscribe-oneclick/route.ts
New Next.js POST handler: parses id and hash from URL query, returns 400 if missing, calls unsubscribeContactFromLink(id, hash), logs info/error, and returns 200 on success or 500 on failure. No List-Unsubscribe header validation performed.
Campaign Service Updates
apps/web/src/server/service/campaign-service.ts
Adds createOneClickUnsubUrl(contactId, campaignId) that builds id, hash, and /api/unsubscribe-oneclick URL; processContactEmail now computes oneClickUnsubUrl and passes it to EmailQueueService.queueEmail. Email body linkValues still use the original unsubscribeUrl.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops with code in tow,
I sew a link that's quick to go.
One click, one hop, the list grows thin,
A carrot cheer for quiet inbox wins. 🥕

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-click-unsubscribe

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 2

🔭 Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)

396-402: Ensure EmailQueueService adds List-Unsubscribe and List-Unsubscribe-Post headers

It appears that EmailQueueService.queueEmail does 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-Click

This 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 generation

Minor 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 instead

Both contact and campaign IDs can contain hyphens (e.g., UUIDs). id.split("-")[1] is unreliable and may produce incorrect campaign IDs. Prefer logging unsubId: 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 shape

Elsewhere 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 POST

Some 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 valid id and hash and 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.

📥 Commits

Reviewing files that changed from the base of the PR and between bb2a428 and d140b53.

📒 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.ts
  • apps/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.ts
  • apps/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, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/app/api/unsubscribe-oneclick/route.ts
  • apps/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.ts
  • apps/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.ts
  • apps/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 scheme

The function correctly mirrors createUnsubUrl with 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 good

It’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.id and Campaign.id are String @default(cuid()), and CUIDs never include hyphens. Splitting on "-" is therefore safe and unambiguous. No changes required.

Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts Outdated
Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts

@coderabbitaicoderabbitaiBot 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.

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 header List-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.
unsubscribeContactFromLink throws 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d140b53 and 9a7c9ab.

📒 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, avoid any, 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.

Comment on lines +11 to +19
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 }
);
}

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.

⚠️ Potential issue

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.

Suggested change
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}
);
}

@KMKoushik
KMKoushik merged commit 43d99bb into mainAug 16, 2025
2 checks passed
@KMKoushik
KMKoushik deleted the fix/one-click-unsubscribe branch August 16, 2025 23:09
@coderabbitaicoderabbitaiBot mentioned this pull request Sep 2, 2025
Sign up for freeto 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.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix one-click unsub from the header - #195

Merged
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe
Aug 16, 2025
Merged

fix one-click unsub from the header#195
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Aug 15, 2025

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Adds one-click unsubscribe support via a new API endpoint so recipients can instantly unsubscribe.
    • Emails now include a queued one-click unsubscribe URL, improving the unsubscribe flow.
    • Endpoint returns clear success/error responses for user actions.
  • Notes / Bug Fixes

    • Request validation only checks presence of id and hash (400 if missing); no List-Unsubscribe header validation.
    • Enhanced logging around unsubscribe events.

@coderabbitai

coderabbitaiBot commented Aug 15, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s)Summary
API: One-Click Unsubscribe Route
apps/web/src/app/api/unsubscribe-oneclick/route.ts
New Next.js POST handler: parses id and hash from URL query, returns 400 if missing, calls unsubscribeContactFromLink(id, hash), logs info/error, and returns 200 on success or 500 on failure. No List-Unsubscribe header validation performed.
Campaign Service Updates
apps/web/src/server/service/campaign-service.ts
Adds createOneClickUnsubUrl(contactId, campaignId) that builds id, hash, and /api/unsubscribe-oneclick URL; processContactEmail now computes oneClickUnsubUrl and passes it to EmailQueueService.queueEmail. Email body linkValues still use the original unsubscribeUrl.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops with code in tow,
I sew a link that's quick to go.
One click, one hop, the list grows thin,
A carrot cheer for quiet inbox wins. 🥕

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-click-unsubscribe

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 2

🔭 Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)

396-402: Ensure EmailQueueService adds List-Unsubscribe and List-Unsubscribe-Post headers

It appears that EmailQueueService.queueEmail does 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-Click

This 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 generation

Minor 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 instead

Both contact and campaign IDs can contain hyphens (e.g., UUIDs). id.split("-")[1] is unreliable and may produce incorrect campaign IDs. Prefer logging unsubId: 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 shape

Elsewhere 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 POST

Some 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 valid id and hash and 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.

📥 Commits

Reviewing files that changed from the base of the PR and between bb2a428 and d140b53.

📒 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.ts
  • apps/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.ts
  • apps/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, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/app/api/unsubscribe-oneclick/route.ts
  • apps/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.ts
  • apps/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.ts
  • apps/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 scheme

The function correctly mirrors createUnsubUrl with 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 good

It’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.id and Campaign.id are String @default(cuid()), and CUIDs never include hyphens. Splitting on "-" is therefore safe and unambiguous. No changes required.

Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts Outdated
Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts

@coderabbitaicoderabbitaiBot 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.

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 header List-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.
unsubscribeContactFromLink throws 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d140b53 and 9a7c9ab.

📒 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, avoid any, 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.

Comment on lines +11 to +19
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 }
);
}

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.

⚠️ Potential issue

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.

Suggested change
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}
);
}

@KMKoushik
KMKoushik merged commit 43d99bb into mainAug 16, 2025
2 checks passed
@KMKoushik
KMKoushik deleted the fix/one-click-unsubscribe branch August 16, 2025 23:09
@coderabbitaicoderabbitaiBot mentioned this pull request Sep 2, 2025
Sign up for freeto 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.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix one-click unsub from the header - #195

Merged
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe
Aug 16, 2025
Merged

fix one-click unsub from the header#195
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Aug 15, 2025

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Adds one-click unsubscribe support via a new API endpoint so recipients can instantly unsubscribe.
    • Emails now include a queued one-click unsubscribe URL, improving the unsubscribe flow.
    • Endpoint returns clear success/error responses for user actions.
  • Notes / Bug Fixes

    • Request validation only checks presence of id and hash (400 if missing); no List-Unsubscribe header validation.
    • Enhanced logging around unsubscribe events.

@coderabbitai

coderabbitaiBot commented Aug 15, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s)Summary
API: One-Click Unsubscribe Route
apps/web/src/app/api/unsubscribe-oneclick/route.ts
New Next.js POST handler: parses id and hash from URL query, returns 400 if missing, calls unsubscribeContactFromLink(id, hash), logs info/error, and returns 200 on success or 500 on failure. No List-Unsubscribe header validation performed.
Campaign Service Updates
apps/web/src/server/service/campaign-service.ts
Adds createOneClickUnsubUrl(contactId, campaignId) that builds id, hash, and /api/unsubscribe-oneclick URL; processContactEmail now computes oneClickUnsubUrl and passes it to EmailQueueService.queueEmail. Email body linkValues still use the original unsubscribeUrl.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops with code in tow,
I sew a link that's quick to go.
One click, one hop, the list grows thin,
A carrot cheer for quiet inbox wins. 🥕

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-click-unsubscribe

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 2

🔭 Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)

396-402: Ensure EmailQueueService adds List-Unsubscribe and List-Unsubscribe-Post headers

It appears that EmailQueueService.queueEmail does 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-Click

This 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 generation

Minor 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 instead

Both contact and campaign IDs can contain hyphens (e.g., UUIDs). id.split("-")[1] is unreliable and may produce incorrect campaign IDs. Prefer logging unsubId: 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 shape

Elsewhere 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 POST

Some 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 valid id and hash and 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.

📥 Commits

Reviewing files that changed from the base of the PR and between bb2a428 and d140b53.

📒 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.ts
  • apps/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.ts
  • apps/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, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/app/api/unsubscribe-oneclick/route.ts
  • apps/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.ts
  • apps/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.ts
  • apps/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 scheme

The function correctly mirrors createUnsubUrl with 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 good

It’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.id and Campaign.id are String @default(cuid()), and CUIDs never include hyphens. Splitting on "-" is therefore safe and unambiguous. No changes required.

Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts Outdated
Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts

@coderabbitaicoderabbitaiBot 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.

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 header List-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.
unsubscribeContactFromLink throws 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d140b53 and 9a7c9ab.

📒 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, avoid any, 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.

Comment on lines +11 to +19
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 }
);
}

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.

⚠️ Potential issue

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.

Suggested change
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}
);
}

@KMKoushik
KMKoushik merged commit 43d99bb into mainAug 16, 2025
2 checks passed
@KMKoushik
KMKoushik deleted the fix/one-click-unsubscribe branch August 16, 2025 23:09
@coderabbitaicoderabbitaiBot mentioned this pull request Sep 2, 2025
Sign up for freeto 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.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix one-click unsub from the header - #195

Merged
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe
Aug 16, 2025
Merged

fix one-click unsub from the header#195
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Aug 15, 2025

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Adds one-click unsubscribe support via a new API endpoint so recipients can instantly unsubscribe.
    • Emails now include a queued one-click unsubscribe URL, improving the unsubscribe flow.
    • Endpoint returns clear success/error responses for user actions.
  • Notes / Bug Fixes

    • Request validation only checks presence of id and hash (400 if missing); no List-Unsubscribe header validation.
    • Enhanced logging around unsubscribe events.

@coderabbitai

coderabbitaiBot commented Aug 15, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s)Summary
API: One-Click Unsubscribe Route
apps/web/src/app/api/unsubscribe-oneclick/route.ts
New Next.js POST handler: parses id and hash from URL query, returns 400 if missing, calls unsubscribeContactFromLink(id, hash), logs info/error, and returns 200 on success or 500 on failure. No List-Unsubscribe header validation performed.
Campaign Service Updates
apps/web/src/server/service/campaign-service.ts
Adds createOneClickUnsubUrl(contactId, campaignId) that builds id, hash, and /api/unsubscribe-oneclick URL; processContactEmail now computes oneClickUnsubUrl and passes it to EmailQueueService.queueEmail. Email body linkValues still use the original unsubscribeUrl.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops with code in tow,
I sew a link that's quick to go.
One click, one hop, the list grows thin,
A carrot cheer for quiet inbox wins. 🥕

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-click-unsubscribe

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 2

🔭 Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)

396-402: Ensure EmailQueueService adds List-Unsubscribe and List-Unsubscribe-Post headers

It appears that EmailQueueService.queueEmail does 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-Click

This 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 generation

Minor 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 instead

Both contact and campaign IDs can contain hyphens (e.g., UUIDs). id.split("-")[1] is unreliable and may produce incorrect campaign IDs. Prefer logging unsubId: 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 shape

Elsewhere 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 POST

Some 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 valid id and hash and 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.

📥 Commits

Reviewing files that changed from the base of the PR and between bb2a428 and d140b53.

📒 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.ts
  • apps/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.ts
  • apps/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, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/app/api/unsubscribe-oneclick/route.ts
  • apps/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.ts
  • apps/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.ts
  • apps/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 scheme

The function correctly mirrors createUnsubUrl with 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 good

It’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.id and Campaign.id are String @default(cuid()), and CUIDs never include hyphens. Splitting on "-" is therefore safe and unambiguous. No changes required.

Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts Outdated
Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts

@coderabbitaicoderabbitaiBot 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.

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 header List-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.
unsubscribeContactFromLink throws 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d140b53 and 9a7c9ab.

📒 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, avoid any, 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.

Comment on lines +11 to +19
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 }
);
}

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.

⚠️ Potential issue

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.

Suggested change
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}
);
}

@KMKoushik
KMKoushik merged commit 43d99bb into mainAug 16, 2025
2 checks passed
@KMKoushik
KMKoushik deleted the fix/one-click-unsubscribe branch August 16, 2025 23:09
@coderabbitaicoderabbitaiBot mentioned this pull request Sep 2, 2025
Sign up for freeto 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.

1 participant

@KMKoushik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix one-click unsub from the header - #195

Merged
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe
Aug 16, 2025
Merged

fix one-click unsub from the header#195
KMKoushik merged 3 commits into
mainfrom
fix/one-click-unsubscribe

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Aug 15, 2025

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Adds one-click unsubscribe support via a new API endpoint so recipients can instantly unsubscribe.
    • Emails now include a queued one-click unsubscribe URL, improving the unsubscribe flow.
    • Endpoint returns clear success/error responses for user actions.
  • Notes / Bug Fixes

    • Request validation only checks presence of id and hash (400 if missing); no List-Unsubscribe header validation.
    • Enhanced logging around unsubscribe events.

@coderabbitai

coderabbitaiBot commented Aug 15, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s)Summary
API: One-Click Unsubscribe Route
apps/web/src/app/api/unsubscribe-oneclick/route.ts
New Next.js POST handler: parses id and hash from URL query, returns 400 if missing, calls unsubscribeContactFromLink(id, hash), logs info/error, and returns 200 on success or 500 on failure. No List-Unsubscribe header validation performed.
Campaign Service Updates
apps/web/src/server/service/campaign-service.ts
Adds createOneClickUnsubUrl(contactId, campaignId) that builds id, hash, and /api/unsubscribe-oneclick URL; processContactEmail now computes oneClickUnsubUrl and passes it to EmailQueueService.queueEmail. Email body linkValues still use the original unsubscribeUrl.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops with code in tow,
I sew a link that's quick to go.
One click, one hop, the list grows thin,
A carrot cheer for quiet inbox wins. 🥕

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 Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/one-click-unsubscribe

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 2

🔭 Outside diff range comments (1)
apps/web/src/server/service/campaign-service.ts (1)

396-402: Ensure EmailQueueService adds List-Unsubscribe and List-Unsubscribe-Post headers

It appears that EmailQueueService.queueEmail does 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-Click

This 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 generation

Minor 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 instead

Both contact and campaign IDs can contain hyphens (e.g., UUIDs). id.split("-")[1] is unreliable and may produce incorrect campaign IDs. Prefer logging unsubId: 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 shape

Elsewhere 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 POST

Some 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 valid id and hash and 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.

📥 Commits

Reviewing files that changed from the base of the PR and between bb2a428 and d140b53.

📒 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.ts
  • apps/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.ts
  • apps/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, avoid any, and use Zod for validation
Follow Vercel style guides with strict TypeScript

Files:

  • apps/web/src/app/api/unsubscribe-oneclick/route.ts
  • apps/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.ts
  • apps/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.ts
  • apps/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 scheme

The function correctly mirrors createUnsubUrl with 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 good

It’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.id and Campaign.id are String @default(cuid()), and CUIDs never include hyphens. Splitting on "-" is therefore safe and unambiguous. No changes required.

Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts Outdated
Comment threadapps/web/src/app/api/unsubscribe-oneclick/route.ts

@coderabbitaicoderabbitaiBot 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.

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 header List-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.
unsubscribeContactFromLink throws 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.

📥 Commits

Reviewing files that changed from the base of the PR and between d140b53 and 9a7c9ab.

📒 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, avoid any, 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.

Comment on lines +11 to +19
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 }
);
}

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.

⚠️ Potential issue

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.

Suggested change
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}
);
}

@KMKoushik
KMKoushik merged commit 43d99bb into mainAug 16, 2025
2 checks passed
@KMKoushik
KMKoushik deleted the fix/one-click-unsubscribe branch August 16, 2025 23:09
@coderabbitaicoderabbitaiBot mentioned this pull request Sep 2, 2025
Sign up for freeto 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.

1 participant

@KMKoushik