fix: require confirmation before campaign unsubscribe - #416

Merged
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get
Jul 10, 2026
Merged

fix: require confirmation before campaign unsubscribe#416
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • make campaign unsubscribe links safe to open with GET
  • render a confirmation screen before changing subscription state
  • perform the unsubscribe through a POST-backed server action
  • preserve the existing RFC 8058 one-click POST endpoint and resubscribe flow
  • add regression coverage proving GET rendering never calls the unsubscribe mutation

Root cause

The in-body campaign unsubscribe page called unsubscribeContactFromLink while rendering a GET request. Email security scanners prefetch links, so they could unsubscribe contacts and increment campaign analytics without a recipient action. The signed hash could not prevent this because scanners received the complete signed URL.

Impact

Opening or scanning an in-body unsubscribe link is now read-only. Contacts are unsubscribed only after explicitly pressing Confirm unsubscribe.

Verification

  • pnpm --filter=web exec vitest run -c vitest.unit.config.ts src/app/unsubscribe/page.unit.test.ts — 2 tests passed
  • local Docker Postgres/Redis flow:
    • old GET: contact became unsubscribed and campaign counter incremented
    • fixed GET: contact remained subscribed and campaign counter stayed unchanged
    • confirmation POST: contact became unsubscribed and campaign counter incremented
  • git diff --check

Closes#412


Summary by cubic

Make campaign unsubscribe links safe to open via GET by adding a confirmation step and moving the unsubscribe to a POST-backed server action. Prevents email scanners from auto-unsubscribing contacts.

  • Bug Fixes
    • Render a confirmation screen on GET; no unsubscribe is executed during render.
    • Add POST server action to finalize unsubscribe; UnsubscribeButton uses useFormStatus for pending state.
    • Preserve the RFC 8058 one‑click POST endpoint and the re‑subscribe flow.
    • Validate link params and show clear, public-safe error messages.
    • Split verification and data access into getContactFromUnsubscribeLink and shared hash validation.
    • Add unit tests to ensure GET rendering never calls the unsubscribe mutation.

Written for commit 6ac4be1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a confirmation step before unsubscribing from email communications.
    • Displays the contact’s current subscription status and offers resubscription when already unsubscribed.
    • Added clear feedback for invalid links and unsubscribe errors.
    • Added a loading state while the unsubscribe request is processing.
  • Accessibility
    • Improved button states and touch-friendly interactions for unsubscribe and resubscribe actions.
  • Tests
    • Added coverage for valid and malformed unsubscribe links.

Entire-Checkpoint: 494da77ae083
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
unsend-marketingReadyReadyPreview, CommentJul 10, 2026 8:39pm

@coderabbitai

coderabbitaiBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The unsubscribe page no longer mutates contact state during GET requests. It validates signed link parameters, loads contact details, renders confirmation or resubscription states, and submits explicit unsubscribe requests through a server action. Errors are mapped to public messages and preserved through redirects. The campaign service centralizes link verification, and unit tests cover valid and malformed query parameters. UI controls now expose pending and disabled states.

Possibly related PRs

  • usesend/useSend#195: Uses the shared unsubscribe-link service behavior for the one-click unsubscribe endpoint.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly states the main behavioral change: requiring confirmation before campaign unsubscribe.
Linked Issues check✅ PassedGET now renders a confirmation UI and the unsubscribe mutation moved behind a POST server action, matching #412.
Out of Scope Changes check✅ PassedThe changes stay focused on unsubscribe flow safety, validation, UI, and regression tests with no clear unrelated additions.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

Latest commit:6ac4be1
Status: ✅ Deploy successful!
Preview URL:https://2faf64ab.usesend.pages.dev
Branch Preview URL:https://codex-fix-unsubscribe-get.usesend.pages.dev

View logs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

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

564-571: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use constant-time comparison for hash verification

hash !== expectedHash is vulnerable to timing attacks. While practical exploitation over HTTP is difficult, using crypto.timingSafeEqual is the standard practice for cryptographic hash comparison and eliminates the attack surface entirely.

🔐 Proposed fix
+import { createHash, timingSafeEqual } from "crypto";
// ... inside verifyUnsubscribeLink:
- if (hash !== expectedHash) {+ if (+ hash.length !== expectedHash.length ||+ !timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))+ ) {
throw new Error("Invalid unsubscribe link");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/server/service/campaign-service.ts` around lines 564 - 571,
Replace the direct comparison in the hash verification logic with
crypto.timingSafeEqual, comparing equal-length byte representations of hash and
expectedHash and handling length mismatches safely before comparison. Preserve
the existing Invalid unsubscribe link error behavior.
🧹 Nitpick comments (1)
apps/web/src/app/unsubscribe/page.unit.test.ts (1)

1-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for error and already-unsubscribed paths

The tests correctly verify the core PR objective (GET does not trigger unsubscribe mutation). Consider adding cases for:

  1. Error path: getContactFromUnsubscribeLink rejects (e.g., invalid hash or contact not found) → page should render MessageCard without calling unsubscribeContactFromLink.
  2. Already unsubscribed: mock returns subscribed: false → page should render ReSubscribe instead of the confirmation form.
🧪 Suggested additional tests
it("renders error card when contact lookup fails",async()=>{campaignService.getContactFromUnsubscribeLink.mockRejectedValueOnce(newError("Invalid unsubscribe link"),);constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "bad-hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});it("renders ReSubscribe when contact is already unsubscribed",async()=>{campaignService.getContactFromUnsubscribeLink.mockResolvedValueOnce({id: "contact-1",email: "person@example.com",subscribed: false,});constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/unsubscribe/page.unit.test.ts` around lines 1 - 45, Add test
coverage in the unsubscribe page test suite for both fallback branches: mock
getContactFromUnsubscribeLink to reject and assert the page renders without
calling unsubscribeContactFromLink, then mock it to return subscribed: false and
assert the already-unsubscribed ReSubscribe view renders without triggering the
mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 564-571: Replace the direct comparison in the hash verification
logic with crypto.timingSafeEqual, comparing equal-length byte representations
of hash and expectedHash and handling length mismatches safely before
comparison. Preserve the existing Invalid unsubscribe link error behavior.
---
Nitpick comments:
In `@apps/web/src/app/unsubscribe/page.unit.test.ts`:
- Around line 1-45: Add test coverage in the unsubscribe page test suite for
both fallback branches: mock getContactFromUnsubscribeLink to reject and assert
the page renders without calling unsubscribeContactFromLink, then mock it to
return subscribed: false and assert the already-unsubscribed ReSubscribe view
renders without triggering the mutation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 19477f38-b94f-444b-ab97-a63d050baee2

📥 Commits

Reviewing files that changed from the base of the PR and between 5259c7b and 6ac4be1.

📒 Files selected for processing (5)
  • apps/web/src/app/unsubscribe/page.tsx
  • apps/web/src/app/unsubscribe/page.unit.test.ts
  • apps/web/src/app/unsubscribe/re-subscribe.tsx
  • apps/web/src/app/unsubscribe/unsubscribe-button.tsx
  • apps/web/src/server/service/campaign-service.ts

@KMKoushik
KMKoushik merged commit 07dc83b into mainJul 10, 2026
5 checks passed
@KMKoushik
KMKoushik deleted the codex/fix-unsubscribe-get branch July 10, 2026 20:57
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.

🐞 - Campaign unsubscribe link unsubscribes on GET (link scanners cause false unsubscribes)

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: require confirmation before campaign unsubscribe - #416

Merged
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get
Jul 10, 2026
Merged

fix: require confirmation before campaign unsubscribe#416
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • make campaign unsubscribe links safe to open with GET
  • render a confirmation screen before changing subscription state
  • perform the unsubscribe through a POST-backed server action
  • preserve the existing RFC 8058 one-click POST endpoint and resubscribe flow
  • add regression coverage proving GET rendering never calls the unsubscribe mutation

Root cause

The in-body campaign unsubscribe page called unsubscribeContactFromLink while rendering a GET request. Email security scanners prefetch links, so they could unsubscribe contacts and increment campaign analytics without a recipient action. The signed hash could not prevent this because scanners received the complete signed URL.

Impact

Opening or scanning an in-body unsubscribe link is now read-only. Contacts are unsubscribed only after explicitly pressing Confirm unsubscribe.

Verification

  • pnpm --filter=web exec vitest run -c vitest.unit.config.ts src/app/unsubscribe/page.unit.test.ts — 2 tests passed
  • local Docker Postgres/Redis flow:
    • old GET: contact became unsubscribed and campaign counter incremented
    • fixed GET: contact remained subscribed and campaign counter stayed unchanged
    • confirmation POST: contact became unsubscribed and campaign counter incremented
  • git diff --check

Closes#412


Summary by cubic

Make campaign unsubscribe links safe to open via GET by adding a confirmation step and moving the unsubscribe to a POST-backed server action. Prevents email scanners from auto-unsubscribing contacts.

  • Bug Fixes
    • Render a confirmation screen on GET; no unsubscribe is executed during render.
    • Add POST server action to finalize unsubscribe; UnsubscribeButton uses useFormStatus for pending state.
    • Preserve the RFC 8058 one‑click POST endpoint and the re‑subscribe flow.
    • Validate link params and show clear, public-safe error messages.
    • Split verification and data access into getContactFromUnsubscribeLink and shared hash validation.
    • Add unit tests to ensure GET rendering never calls the unsubscribe mutation.

Written for commit 6ac4be1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a confirmation step before unsubscribing from email communications.
    • Displays the contact’s current subscription status and offers resubscription when already unsubscribed.
    • Added clear feedback for invalid links and unsubscribe errors.
    • Added a loading state while the unsubscribe request is processing.
  • Accessibility
    • Improved button states and touch-friendly interactions for unsubscribe and resubscribe actions.
  • Tests
    • Added coverage for valid and malformed unsubscribe links.

Entire-Checkpoint: 494da77ae083
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
unsend-marketingReadyReadyPreview, CommentJul 10, 2026 8:39pm

@coderabbitai

coderabbitaiBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The unsubscribe page no longer mutates contact state during GET requests. It validates signed link parameters, loads contact details, renders confirmation or resubscription states, and submits explicit unsubscribe requests through a server action. Errors are mapped to public messages and preserved through redirects. The campaign service centralizes link verification, and unit tests cover valid and malformed query parameters. UI controls now expose pending and disabled states.

Possibly related PRs

  • usesend/useSend#195: Uses the shared unsubscribe-link service behavior for the one-click unsubscribe endpoint.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly states the main behavioral change: requiring confirmation before campaign unsubscribe.
Linked Issues check✅ PassedGET now renders a confirmation UI and the unsubscribe mutation moved behind a POST server action, matching #412.
Out of Scope Changes check✅ PassedThe changes stay focused on unsubscribe flow safety, validation, UI, and regression tests with no clear unrelated additions.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

Latest commit:6ac4be1
Status: ✅ Deploy successful!
Preview URL:https://2faf64ab.usesend.pages.dev
Branch Preview URL:https://codex-fix-unsubscribe-get.usesend.pages.dev

View logs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

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

564-571: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use constant-time comparison for hash verification

hash !== expectedHash is vulnerable to timing attacks. While practical exploitation over HTTP is difficult, using crypto.timingSafeEqual is the standard practice for cryptographic hash comparison and eliminates the attack surface entirely.

🔐 Proposed fix
+import { createHash, timingSafeEqual } from "crypto";
// ... inside verifyUnsubscribeLink:
- if (hash !== expectedHash) {+ if (+ hash.length !== expectedHash.length ||+ !timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))+ ) {
throw new Error("Invalid unsubscribe link");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/server/service/campaign-service.ts` around lines 564 - 571,
Replace the direct comparison in the hash verification logic with
crypto.timingSafeEqual, comparing equal-length byte representations of hash and
expectedHash and handling length mismatches safely before comparison. Preserve
the existing Invalid unsubscribe link error behavior.
🧹 Nitpick comments (1)
apps/web/src/app/unsubscribe/page.unit.test.ts (1)

1-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for error and already-unsubscribed paths

The tests correctly verify the core PR objective (GET does not trigger unsubscribe mutation). Consider adding cases for:

  1. Error path: getContactFromUnsubscribeLink rejects (e.g., invalid hash or contact not found) → page should render MessageCard without calling unsubscribeContactFromLink.
  2. Already unsubscribed: mock returns subscribed: false → page should render ReSubscribe instead of the confirmation form.
🧪 Suggested additional tests
it("renders error card when contact lookup fails",async()=>{campaignService.getContactFromUnsubscribeLink.mockRejectedValueOnce(newError("Invalid unsubscribe link"),);constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "bad-hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});it("renders ReSubscribe when contact is already unsubscribed",async()=>{campaignService.getContactFromUnsubscribeLink.mockResolvedValueOnce({id: "contact-1",email: "person@example.com",subscribed: false,});constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/unsubscribe/page.unit.test.ts` around lines 1 - 45, Add test
coverage in the unsubscribe page test suite for both fallback branches: mock
getContactFromUnsubscribeLink to reject and assert the page renders without
calling unsubscribeContactFromLink, then mock it to return subscribed: false and
assert the already-unsubscribed ReSubscribe view renders without triggering the
mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 564-571: Replace the direct comparison in the hash verification
logic with crypto.timingSafeEqual, comparing equal-length byte representations
of hash and expectedHash and handling length mismatches safely before
comparison. Preserve the existing Invalid unsubscribe link error behavior.
---
Nitpick comments:
In `@apps/web/src/app/unsubscribe/page.unit.test.ts`:
- Around line 1-45: Add test coverage in the unsubscribe page test suite for
both fallback branches: mock getContactFromUnsubscribeLink to reject and assert
the page renders without calling unsubscribeContactFromLink, then mock it to
return subscribed: false and assert the already-unsubscribed ReSubscribe view
renders without triggering the mutation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 19477f38-b94f-444b-ab97-a63d050baee2

📥 Commits

Reviewing files that changed from the base of the PR and between 5259c7b and 6ac4be1.

📒 Files selected for processing (5)
  • apps/web/src/app/unsubscribe/page.tsx
  • apps/web/src/app/unsubscribe/page.unit.test.ts
  • apps/web/src/app/unsubscribe/re-subscribe.tsx
  • apps/web/src/app/unsubscribe/unsubscribe-button.tsx
  • apps/web/src/server/service/campaign-service.ts

@KMKoushik
KMKoushik merged commit 07dc83b into mainJul 10, 2026
5 checks passed
@KMKoushik
KMKoushik deleted the codex/fix-unsubscribe-get branch July 10, 2026 20:57
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.

🐞 - Campaign unsubscribe link unsubscribes on GET (link scanners cause false unsubscribes)

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: require confirmation before campaign unsubscribe - #416

Merged
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get
Jul 10, 2026
Merged

fix: require confirmation before campaign unsubscribe#416
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • make campaign unsubscribe links safe to open with GET
  • render a confirmation screen before changing subscription state
  • perform the unsubscribe through a POST-backed server action
  • preserve the existing RFC 8058 one-click POST endpoint and resubscribe flow
  • add regression coverage proving GET rendering never calls the unsubscribe mutation

Root cause

The in-body campaign unsubscribe page called unsubscribeContactFromLink while rendering a GET request. Email security scanners prefetch links, so they could unsubscribe contacts and increment campaign analytics without a recipient action. The signed hash could not prevent this because scanners received the complete signed URL.

Impact

Opening or scanning an in-body unsubscribe link is now read-only. Contacts are unsubscribed only after explicitly pressing Confirm unsubscribe.

Verification

  • pnpm --filter=web exec vitest run -c vitest.unit.config.ts src/app/unsubscribe/page.unit.test.ts — 2 tests passed
  • local Docker Postgres/Redis flow:
    • old GET: contact became unsubscribed and campaign counter incremented
    • fixed GET: contact remained subscribed and campaign counter stayed unchanged
    • confirmation POST: contact became unsubscribed and campaign counter incremented
  • git diff --check

Closes#412


Summary by cubic

Make campaign unsubscribe links safe to open via GET by adding a confirmation step and moving the unsubscribe to a POST-backed server action. Prevents email scanners from auto-unsubscribing contacts.

  • Bug Fixes
    • Render a confirmation screen on GET; no unsubscribe is executed during render.
    • Add POST server action to finalize unsubscribe; UnsubscribeButton uses useFormStatus for pending state.
    • Preserve the RFC 8058 one‑click POST endpoint and the re‑subscribe flow.
    • Validate link params and show clear, public-safe error messages.
    • Split verification and data access into getContactFromUnsubscribeLink and shared hash validation.
    • Add unit tests to ensure GET rendering never calls the unsubscribe mutation.

Written for commit 6ac4be1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a confirmation step before unsubscribing from email communications.
    • Displays the contact’s current subscription status and offers resubscription when already unsubscribed.
    • Added clear feedback for invalid links and unsubscribe errors.
    • Added a loading state while the unsubscribe request is processing.
  • Accessibility
    • Improved button states and touch-friendly interactions for unsubscribe and resubscribe actions.
  • Tests
    • Added coverage for valid and malformed unsubscribe links.

Entire-Checkpoint: 494da77ae083
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
unsend-marketingReadyReadyPreview, CommentJul 10, 2026 8:39pm

@coderabbitai

coderabbitaiBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The unsubscribe page no longer mutates contact state during GET requests. It validates signed link parameters, loads contact details, renders confirmation or resubscription states, and submits explicit unsubscribe requests through a server action. Errors are mapped to public messages and preserved through redirects. The campaign service centralizes link verification, and unit tests cover valid and malformed query parameters. UI controls now expose pending and disabled states.

Possibly related PRs

  • usesend/useSend#195: Uses the shared unsubscribe-link service behavior for the one-click unsubscribe endpoint.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly states the main behavioral change: requiring confirmation before campaign unsubscribe.
Linked Issues check✅ PassedGET now renders a confirmation UI and the unsubscribe mutation moved behind a POST server action, matching #412.
Out of Scope Changes check✅ PassedThe changes stay focused on unsubscribe flow safety, validation, UI, and regression tests with no clear unrelated additions.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

Latest commit:6ac4be1
Status: ✅ Deploy successful!
Preview URL:https://2faf64ab.usesend.pages.dev
Branch Preview URL:https://codex-fix-unsubscribe-get.usesend.pages.dev

View logs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

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

564-571: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use constant-time comparison for hash verification

hash !== expectedHash is vulnerable to timing attacks. While practical exploitation over HTTP is difficult, using crypto.timingSafeEqual is the standard practice for cryptographic hash comparison and eliminates the attack surface entirely.

🔐 Proposed fix
+import { createHash, timingSafeEqual } from "crypto";
// ... inside verifyUnsubscribeLink:
- if (hash !== expectedHash) {+ if (+ hash.length !== expectedHash.length ||+ !timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))+ ) {
throw new Error("Invalid unsubscribe link");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/server/service/campaign-service.ts` around lines 564 - 571,
Replace the direct comparison in the hash verification logic with
crypto.timingSafeEqual, comparing equal-length byte representations of hash and
expectedHash and handling length mismatches safely before comparison. Preserve
the existing Invalid unsubscribe link error behavior.
🧹 Nitpick comments (1)
apps/web/src/app/unsubscribe/page.unit.test.ts (1)

1-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for error and already-unsubscribed paths

The tests correctly verify the core PR objective (GET does not trigger unsubscribe mutation). Consider adding cases for:

  1. Error path: getContactFromUnsubscribeLink rejects (e.g., invalid hash or contact not found) → page should render MessageCard without calling unsubscribeContactFromLink.
  2. Already unsubscribed: mock returns subscribed: false → page should render ReSubscribe instead of the confirmation form.
🧪 Suggested additional tests
it("renders error card when contact lookup fails",async()=>{campaignService.getContactFromUnsubscribeLink.mockRejectedValueOnce(newError("Invalid unsubscribe link"),);constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "bad-hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});it("renders ReSubscribe when contact is already unsubscribed",async()=>{campaignService.getContactFromUnsubscribeLink.mockResolvedValueOnce({id: "contact-1",email: "person@example.com",subscribed: false,});constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/unsubscribe/page.unit.test.ts` around lines 1 - 45, Add test
coverage in the unsubscribe page test suite for both fallback branches: mock
getContactFromUnsubscribeLink to reject and assert the page renders without
calling unsubscribeContactFromLink, then mock it to return subscribed: false and
assert the already-unsubscribed ReSubscribe view renders without triggering the
mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 564-571: Replace the direct comparison in the hash verification
logic with crypto.timingSafeEqual, comparing equal-length byte representations
of hash and expectedHash and handling length mismatches safely before
comparison. Preserve the existing Invalid unsubscribe link error behavior.
---
Nitpick comments:
In `@apps/web/src/app/unsubscribe/page.unit.test.ts`:
- Around line 1-45: Add test coverage in the unsubscribe page test suite for
both fallback branches: mock getContactFromUnsubscribeLink to reject and assert
the page renders without calling unsubscribeContactFromLink, then mock it to
return subscribed: false and assert the already-unsubscribed ReSubscribe view
renders without triggering the mutation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 19477f38-b94f-444b-ab97-a63d050baee2

📥 Commits

Reviewing files that changed from the base of the PR and between 5259c7b and 6ac4be1.

📒 Files selected for processing (5)
  • apps/web/src/app/unsubscribe/page.tsx
  • apps/web/src/app/unsubscribe/page.unit.test.ts
  • apps/web/src/app/unsubscribe/re-subscribe.tsx
  • apps/web/src/app/unsubscribe/unsubscribe-button.tsx
  • apps/web/src/server/service/campaign-service.ts

@KMKoushik
KMKoushik merged commit 07dc83b into mainJul 10, 2026
5 checks passed
@KMKoushik
KMKoushik deleted the codex/fix-unsubscribe-get branch July 10, 2026 20:57
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.

🐞 - Campaign unsubscribe link unsubscribes on GET (link scanners cause false unsubscribes)

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: require confirmation before campaign unsubscribe - #416

Merged
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get
Jul 10, 2026
Merged

fix: require confirmation before campaign unsubscribe#416
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • make campaign unsubscribe links safe to open with GET
  • render a confirmation screen before changing subscription state
  • perform the unsubscribe through a POST-backed server action
  • preserve the existing RFC 8058 one-click POST endpoint and resubscribe flow
  • add regression coverage proving GET rendering never calls the unsubscribe mutation

Root cause

The in-body campaign unsubscribe page called unsubscribeContactFromLink while rendering a GET request. Email security scanners prefetch links, so they could unsubscribe contacts and increment campaign analytics without a recipient action. The signed hash could not prevent this because scanners received the complete signed URL.

Impact

Opening or scanning an in-body unsubscribe link is now read-only. Contacts are unsubscribed only after explicitly pressing Confirm unsubscribe.

Verification

  • pnpm --filter=web exec vitest run -c vitest.unit.config.ts src/app/unsubscribe/page.unit.test.ts — 2 tests passed
  • local Docker Postgres/Redis flow:
    • old GET: contact became unsubscribed and campaign counter incremented
    • fixed GET: contact remained subscribed and campaign counter stayed unchanged
    • confirmation POST: contact became unsubscribed and campaign counter incremented
  • git diff --check

Closes#412


Summary by cubic

Make campaign unsubscribe links safe to open via GET by adding a confirmation step and moving the unsubscribe to a POST-backed server action. Prevents email scanners from auto-unsubscribing contacts.

  • Bug Fixes
    • Render a confirmation screen on GET; no unsubscribe is executed during render.
    • Add POST server action to finalize unsubscribe; UnsubscribeButton uses useFormStatus for pending state.
    • Preserve the RFC 8058 one‑click POST endpoint and the re‑subscribe flow.
    • Validate link params and show clear, public-safe error messages.
    • Split verification and data access into getContactFromUnsubscribeLink and shared hash validation.
    • Add unit tests to ensure GET rendering never calls the unsubscribe mutation.

Written for commit 6ac4be1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a confirmation step before unsubscribing from email communications.
    • Displays the contact’s current subscription status and offers resubscription when already unsubscribed.
    • Added clear feedback for invalid links and unsubscribe errors.
    • Added a loading state while the unsubscribe request is processing.
  • Accessibility
    • Improved button states and touch-friendly interactions for unsubscribe and resubscribe actions.
  • Tests
    • Added coverage for valid and malformed unsubscribe links.

Entire-Checkpoint: 494da77ae083
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
unsend-marketingReadyReadyPreview, CommentJul 10, 2026 8:39pm

@coderabbitai

coderabbitaiBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The unsubscribe page no longer mutates contact state during GET requests. It validates signed link parameters, loads contact details, renders confirmation or resubscription states, and submits explicit unsubscribe requests through a server action. Errors are mapped to public messages and preserved through redirects. The campaign service centralizes link verification, and unit tests cover valid and malformed query parameters. UI controls now expose pending and disabled states.

Possibly related PRs

  • usesend/useSend#195: Uses the shared unsubscribe-link service behavior for the one-click unsubscribe endpoint.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly states the main behavioral change: requiring confirmation before campaign unsubscribe.
Linked Issues check✅ PassedGET now renders a confirmation UI and the unsubscribe mutation moved behind a POST server action, matching #412.
Out of Scope Changes check✅ PassedThe changes stay focused on unsubscribe flow safety, validation, UI, and regression tests with no clear unrelated additions.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

Latest commit:6ac4be1
Status: ✅ Deploy successful!
Preview URL:https://2faf64ab.usesend.pages.dev
Branch Preview URL:https://codex-fix-unsubscribe-get.usesend.pages.dev

View logs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

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

564-571: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use constant-time comparison for hash verification

hash !== expectedHash is vulnerable to timing attacks. While practical exploitation over HTTP is difficult, using crypto.timingSafeEqual is the standard practice for cryptographic hash comparison and eliminates the attack surface entirely.

🔐 Proposed fix
+import { createHash, timingSafeEqual } from "crypto";
// ... inside verifyUnsubscribeLink:
- if (hash !== expectedHash) {+ if (+ hash.length !== expectedHash.length ||+ !timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))+ ) {
throw new Error("Invalid unsubscribe link");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/server/service/campaign-service.ts` around lines 564 - 571,
Replace the direct comparison in the hash verification logic with
crypto.timingSafeEqual, comparing equal-length byte representations of hash and
expectedHash and handling length mismatches safely before comparison. Preserve
the existing Invalid unsubscribe link error behavior.
🧹 Nitpick comments (1)
apps/web/src/app/unsubscribe/page.unit.test.ts (1)

1-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for error and already-unsubscribed paths

The tests correctly verify the core PR objective (GET does not trigger unsubscribe mutation). Consider adding cases for:

  1. Error path: getContactFromUnsubscribeLink rejects (e.g., invalid hash or contact not found) → page should render MessageCard without calling unsubscribeContactFromLink.
  2. Already unsubscribed: mock returns subscribed: false → page should render ReSubscribe instead of the confirmation form.
🧪 Suggested additional tests
it("renders error card when contact lookup fails",async()=>{campaignService.getContactFromUnsubscribeLink.mockRejectedValueOnce(newError("Invalid unsubscribe link"),);constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "bad-hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});it("renders ReSubscribe when contact is already unsubscribed",async()=>{campaignService.getContactFromUnsubscribeLink.mockResolvedValueOnce({id: "contact-1",email: "person@example.com",subscribed: false,});constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/unsubscribe/page.unit.test.ts` around lines 1 - 45, Add test
coverage in the unsubscribe page test suite for both fallback branches: mock
getContactFromUnsubscribeLink to reject and assert the page renders without
calling unsubscribeContactFromLink, then mock it to return subscribed: false and
assert the already-unsubscribed ReSubscribe view renders without triggering the
mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 564-571: Replace the direct comparison in the hash verification
logic with crypto.timingSafeEqual, comparing equal-length byte representations
of hash and expectedHash and handling length mismatches safely before
comparison. Preserve the existing Invalid unsubscribe link error behavior.
---
Nitpick comments:
In `@apps/web/src/app/unsubscribe/page.unit.test.ts`:
- Around line 1-45: Add test coverage in the unsubscribe page test suite for
both fallback branches: mock getContactFromUnsubscribeLink to reject and assert
the page renders without calling unsubscribeContactFromLink, then mock it to
return subscribed: false and assert the already-unsubscribed ReSubscribe view
renders without triggering the mutation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 19477f38-b94f-444b-ab97-a63d050baee2

📥 Commits

Reviewing files that changed from the base of the PR and between 5259c7b and 6ac4be1.

📒 Files selected for processing (5)
  • apps/web/src/app/unsubscribe/page.tsx
  • apps/web/src/app/unsubscribe/page.unit.test.ts
  • apps/web/src/app/unsubscribe/re-subscribe.tsx
  • apps/web/src/app/unsubscribe/unsubscribe-button.tsx
  • apps/web/src/server/service/campaign-service.ts

@KMKoushik
KMKoushik merged commit 07dc83b into mainJul 10, 2026
5 checks passed
@KMKoushik
KMKoushik deleted the codex/fix-unsubscribe-get branch July 10, 2026 20:57
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.

🐞 - Campaign unsubscribe link unsubscribes on GET (link scanners cause false unsubscribes)

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: require confirmation before campaign unsubscribe - #416

Merged
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get
Jul 10, 2026
Merged

fix: require confirmation before campaign unsubscribe#416
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • make campaign unsubscribe links safe to open with GET
  • render a confirmation screen before changing subscription state
  • perform the unsubscribe through a POST-backed server action
  • preserve the existing RFC 8058 one-click POST endpoint and resubscribe flow
  • add regression coverage proving GET rendering never calls the unsubscribe mutation

Root cause

The in-body campaign unsubscribe page called unsubscribeContactFromLink while rendering a GET request. Email security scanners prefetch links, so they could unsubscribe contacts and increment campaign analytics without a recipient action. The signed hash could not prevent this because scanners received the complete signed URL.

Impact

Opening or scanning an in-body unsubscribe link is now read-only. Contacts are unsubscribed only after explicitly pressing Confirm unsubscribe.

Verification

  • pnpm --filter=web exec vitest run -c vitest.unit.config.ts src/app/unsubscribe/page.unit.test.ts — 2 tests passed
  • local Docker Postgres/Redis flow:
    • old GET: contact became unsubscribed and campaign counter incremented
    • fixed GET: contact remained subscribed and campaign counter stayed unchanged
    • confirmation POST: contact became unsubscribed and campaign counter incremented
  • git diff --check

Closes#412


Summary by cubic

Make campaign unsubscribe links safe to open via GET by adding a confirmation step and moving the unsubscribe to a POST-backed server action. Prevents email scanners from auto-unsubscribing contacts.

  • Bug Fixes
    • Render a confirmation screen on GET; no unsubscribe is executed during render.
    • Add POST server action to finalize unsubscribe; UnsubscribeButton uses useFormStatus for pending state.
    • Preserve the RFC 8058 one‑click POST endpoint and the re‑subscribe flow.
    • Validate link params and show clear, public-safe error messages.
    • Split verification and data access into getContactFromUnsubscribeLink and shared hash validation.
    • Add unit tests to ensure GET rendering never calls the unsubscribe mutation.

Written for commit 6ac4be1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a confirmation step before unsubscribing from email communications.
    • Displays the contact’s current subscription status and offers resubscription when already unsubscribed.
    • Added clear feedback for invalid links and unsubscribe errors.
    • Added a loading state while the unsubscribe request is processing.
  • Accessibility
    • Improved button states and touch-friendly interactions for unsubscribe and resubscribe actions.
  • Tests
    • Added coverage for valid and malformed unsubscribe links.

Entire-Checkpoint: 494da77ae083
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
unsend-marketingReadyReadyPreview, CommentJul 10, 2026 8:39pm

@coderabbitai

coderabbitaiBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The unsubscribe page no longer mutates contact state during GET requests. It validates signed link parameters, loads contact details, renders confirmation or resubscription states, and submits explicit unsubscribe requests through a server action. Errors are mapped to public messages and preserved through redirects. The campaign service centralizes link verification, and unit tests cover valid and malformed query parameters. UI controls now expose pending and disabled states.

Possibly related PRs

  • usesend/useSend#195: Uses the shared unsubscribe-link service behavior for the one-click unsubscribe endpoint.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly states the main behavioral change: requiring confirmation before campaign unsubscribe.
Linked Issues check✅ PassedGET now renders a confirmation UI and the unsubscribe mutation moved behind a POST server action, matching #412.
Out of Scope Changes check✅ PassedThe changes stay focused on unsubscribe flow safety, validation, UI, and regression tests with no clear unrelated additions.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

Latest commit:6ac4be1
Status: ✅ Deploy successful!
Preview URL:https://2faf64ab.usesend.pages.dev
Branch Preview URL:https://codex-fix-unsubscribe-get.usesend.pages.dev

View logs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

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

564-571: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use constant-time comparison for hash verification

hash !== expectedHash is vulnerable to timing attacks. While practical exploitation over HTTP is difficult, using crypto.timingSafeEqual is the standard practice for cryptographic hash comparison and eliminates the attack surface entirely.

🔐 Proposed fix
+import { createHash, timingSafeEqual } from "crypto";
// ... inside verifyUnsubscribeLink:
- if (hash !== expectedHash) {+ if (+ hash.length !== expectedHash.length ||+ !timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))+ ) {
throw new Error("Invalid unsubscribe link");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/server/service/campaign-service.ts` around lines 564 - 571,
Replace the direct comparison in the hash verification logic with
crypto.timingSafeEqual, comparing equal-length byte representations of hash and
expectedHash and handling length mismatches safely before comparison. Preserve
the existing Invalid unsubscribe link error behavior.
🧹 Nitpick comments (1)
apps/web/src/app/unsubscribe/page.unit.test.ts (1)

1-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for error and already-unsubscribed paths

The tests correctly verify the core PR objective (GET does not trigger unsubscribe mutation). Consider adding cases for:

  1. Error path: getContactFromUnsubscribeLink rejects (e.g., invalid hash or contact not found) → page should render MessageCard without calling unsubscribeContactFromLink.
  2. Already unsubscribed: mock returns subscribed: false → page should render ReSubscribe instead of the confirmation form.
🧪 Suggested additional tests
it("renders error card when contact lookup fails",async()=>{campaignService.getContactFromUnsubscribeLink.mockRejectedValueOnce(newError("Invalid unsubscribe link"),);constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "bad-hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});it("renders ReSubscribe when contact is already unsubscribed",async()=>{campaignService.getContactFromUnsubscribeLink.mockResolvedValueOnce({id: "contact-1",email: "person@example.com",subscribed: false,});constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/unsubscribe/page.unit.test.ts` around lines 1 - 45, Add test
coverage in the unsubscribe page test suite for both fallback branches: mock
getContactFromUnsubscribeLink to reject and assert the page renders without
calling unsubscribeContactFromLink, then mock it to return subscribed: false and
assert the already-unsubscribed ReSubscribe view renders without triggering the
mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 564-571: Replace the direct comparison in the hash verification
logic with crypto.timingSafeEqual, comparing equal-length byte representations
of hash and expectedHash and handling length mismatches safely before
comparison. Preserve the existing Invalid unsubscribe link error behavior.
---
Nitpick comments:
In `@apps/web/src/app/unsubscribe/page.unit.test.ts`:
- Around line 1-45: Add test coverage in the unsubscribe page test suite for
both fallback branches: mock getContactFromUnsubscribeLink to reject and assert
the page renders without calling unsubscribeContactFromLink, then mock it to
return subscribed: false and assert the already-unsubscribed ReSubscribe view
renders without triggering the mutation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 19477f38-b94f-444b-ab97-a63d050baee2

📥 Commits

Reviewing files that changed from the base of the PR and between 5259c7b and 6ac4be1.

📒 Files selected for processing (5)
  • apps/web/src/app/unsubscribe/page.tsx
  • apps/web/src/app/unsubscribe/page.unit.test.ts
  • apps/web/src/app/unsubscribe/re-subscribe.tsx
  • apps/web/src/app/unsubscribe/unsubscribe-button.tsx
  • apps/web/src/server/service/campaign-service.ts

@KMKoushik
KMKoushik merged commit 07dc83b into mainJul 10, 2026
5 checks passed
@KMKoushik
KMKoushik deleted the codex/fix-unsubscribe-get branch July 10, 2026 20:57
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.

🐞 - Campaign unsubscribe link unsubscribes on GET (link scanners cause false unsubscribes)

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: require confirmation before campaign unsubscribe - #416

Merged
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get
Jul 10, 2026
Merged

fix: require confirmation before campaign unsubscribe#416
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • make campaign unsubscribe links safe to open with GET
  • render a confirmation screen before changing subscription state
  • perform the unsubscribe through a POST-backed server action
  • preserve the existing RFC 8058 one-click POST endpoint and resubscribe flow
  • add regression coverage proving GET rendering never calls the unsubscribe mutation

Root cause

The in-body campaign unsubscribe page called unsubscribeContactFromLink while rendering a GET request. Email security scanners prefetch links, so they could unsubscribe contacts and increment campaign analytics without a recipient action. The signed hash could not prevent this because scanners received the complete signed URL.

Impact

Opening or scanning an in-body unsubscribe link is now read-only. Contacts are unsubscribed only after explicitly pressing Confirm unsubscribe.

Verification

  • pnpm --filter=web exec vitest run -c vitest.unit.config.ts src/app/unsubscribe/page.unit.test.ts — 2 tests passed
  • local Docker Postgres/Redis flow:
    • old GET: contact became unsubscribed and campaign counter incremented
    • fixed GET: contact remained subscribed and campaign counter stayed unchanged
    • confirmation POST: contact became unsubscribed and campaign counter incremented
  • git diff --check

Closes#412


Summary by cubic

Make campaign unsubscribe links safe to open via GET by adding a confirmation step and moving the unsubscribe to a POST-backed server action. Prevents email scanners from auto-unsubscribing contacts.

  • Bug Fixes
    • Render a confirmation screen on GET; no unsubscribe is executed during render.
    • Add POST server action to finalize unsubscribe; UnsubscribeButton uses useFormStatus for pending state.
    • Preserve the RFC 8058 one‑click POST endpoint and the re‑subscribe flow.
    • Validate link params and show clear, public-safe error messages.
    • Split verification and data access into getContactFromUnsubscribeLink and shared hash validation.
    • Add unit tests to ensure GET rendering never calls the unsubscribe mutation.

Written for commit 6ac4be1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a confirmation step before unsubscribing from email communications.
    • Displays the contact’s current subscription status and offers resubscription when already unsubscribed.
    • Added clear feedback for invalid links and unsubscribe errors.
    • Added a loading state while the unsubscribe request is processing.
  • Accessibility
    • Improved button states and touch-friendly interactions for unsubscribe and resubscribe actions.
  • Tests
    • Added coverage for valid and malformed unsubscribe links.

Entire-Checkpoint: 494da77ae083
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
unsend-marketingReadyReadyPreview, CommentJul 10, 2026 8:39pm

@coderabbitai

coderabbitaiBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The unsubscribe page no longer mutates contact state during GET requests. It validates signed link parameters, loads contact details, renders confirmation or resubscription states, and submits explicit unsubscribe requests through a server action. Errors are mapped to public messages and preserved through redirects. The campaign service centralizes link verification, and unit tests cover valid and malformed query parameters. UI controls now expose pending and disabled states.

Possibly related PRs

  • usesend/useSend#195: Uses the shared unsubscribe-link service behavior for the one-click unsubscribe endpoint.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly states the main behavioral change: requiring confirmation before campaign unsubscribe.
Linked Issues check✅ PassedGET now renders a confirmation UI and the unsubscribe mutation moved behind a POST server action, matching #412.
Out of Scope Changes check✅ PassedThe changes stay focused on unsubscribe flow safety, validation, UI, and regression tests with no clear unrelated additions.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

Latest commit:6ac4be1
Status: ✅ Deploy successful!
Preview URL:https://2faf64ab.usesend.pages.dev
Branch Preview URL:https://codex-fix-unsubscribe-get.usesend.pages.dev

View logs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

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

564-571: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use constant-time comparison for hash verification

hash !== expectedHash is vulnerable to timing attacks. While practical exploitation over HTTP is difficult, using crypto.timingSafeEqual is the standard practice for cryptographic hash comparison and eliminates the attack surface entirely.

🔐 Proposed fix
+import { createHash, timingSafeEqual } from "crypto";
// ... inside verifyUnsubscribeLink:
- if (hash !== expectedHash) {+ if (+ hash.length !== expectedHash.length ||+ !timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))+ ) {
throw new Error("Invalid unsubscribe link");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/server/service/campaign-service.ts` around lines 564 - 571,
Replace the direct comparison in the hash verification logic with
crypto.timingSafeEqual, comparing equal-length byte representations of hash and
expectedHash and handling length mismatches safely before comparison. Preserve
the existing Invalid unsubscribe link error behavior.
🧹 Nitpick comments (1)
apps/web/src/app/unsubscribe/page.unit.test.ts (1)

1-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for error and already-unsubscribed paths

The tests correctly verify the core PR objective (GET does not trigger unsubscribe mutation). Consider adding cases for:

  1. Error path: getContactFromUnsubscribeLink rejects (e.g., invalid hash or contact not found) → page should render MessageCard without calling unsubscribeContactFromLink.
  2. Already unsubscribed: mock returns subscribed: false → page should render ReSubscribe instead of the confirmation form.
🧪 Suggested additional tests
it("renders error card when contact lookup fails",async()=>{campaignService.getContactFromUnsubscribeLink.mockRejectedValueOnce(newError("Invalid unsubscribe link"),);constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "bad-hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});it("renders ReSubscribe when contact is already unsubscribed",async()=>{campaignService.getContactFromUnsubscribeLink.mockResolvedValueOnce({id: "contact-1",email: "person@example.com",subscribed: false,});constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/unsubscribe/page.unit.test.ts` around lines 1 - 45, Add test
coverage in the unsubscribe page test suite for both fallback branches: mock
getContactFromUnsubscribeLink to reject and assert the page renders without
calling unsubscribeContactFromLink, then mock it to return subscribed: false and
assert the already-unsubscribed ReSubscribe view renders without triggering the
mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 564-571: Replace the direct comparison in the hash verification
logic with crypto.timingSafeEqual, comparing equal-length byte representations
of hash and expectedHash and handling length mismatches safely before
comparison. Preserve the existing Invalid unsubscribe link error behavior.
---
Nitpick comments:
In `@apps/web/src/app/unsubscribe/page.unit.test.ts`:
- Around line 1-45: Add test coverage in the unsubscribe page test suite for
both fallback branches: mock getContactFromUnsubscribeLink to reject and assert
the page renders without calling unsubscribeContactFromLink, then mock it to
return subscribed: false and assert the already-unsubscribed ReSubscribe view
renders without triggering the mutation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 19477f38-b94f-444b-ab97-a63d050baee2

📥 Commits

Reviewing files that changed from the base of the PR and between 5259c7b and 6ac4be1.

📒 Files selected for processing (5)
  • apps/web/src/app/unsubscribe/page.tsx
  • apps/web/src/app/unsubscribe/page.unit.test.ts
  • apps/web/src/app/unsubscribe/re-subscribe.tsx
  • apps/web/src/app/unsubscribe/unsubscribe-button.tsx
  • apps/web/src/server/service/campaign-service.ts

@KMKoushik
KMKoushik merged commit 07dc83b into mainJul 10, 2026
5 checks passed
@KMKoushik
KMKoushik deleted the codex/fix-unsubscribe-get branch July 10, 2026 20:57
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.

🐞 - Campaign unsubscribe link unsubscribes on GET (link scanners cause false unsubscribes)

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: require confirmation before campaign unsubscribe - #416

Merged
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get
Jul 10, 2026
Merged

fix: require confirmation before campaign unsubscribe#416
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • make campaign unsubscribe links safe to open with GET
  • render a confirmation screen before changing subscription state
  • perform the unsubscribe through a POST-backed server action
  • preserve the existing RFC 8058 one-click POST endpoint and resubscribe flow
  • add regression coverage proving GET rendering never calls the unsubscribe mutation

Root cause

The in-body campaign unsubscribe page called unsubscribeContactFromLink while rendering a GET request. Email security scanners prefetch links, so they could unsubscribe contacts and increment campaign analytics without a recipient action. The signed hash could not prevent this because scanners received the complete signed URL.

Impact

Opening or scanning an in-body unsubscribe link is now read-only. Contacts are unsubscribed only after explicitly pressing Confirm unsubscribe.

Verification

  • pnpm --filter=web exec vitest run -c vitest.unit.config.ts src/app/unsubscribe/page.unit.test.ts — 2 tests passed
  • local Docker Postgres/Redis flow:
    • old GET: contact became unsubscribed and campaign counter incremented
    • fixed GET: contact remained subscribed and campaign counter stayed unchanged
    • confirmation POST: contact became unsubscribed and campaign counter incremented
  • git diff --check

Closes#412


Summary by cubic

Make campaign unsubscribe links safe to open via GET by adding a confirmation step and moving the unsubscribe to a POST-backed server action. Prevents email scanners from auto-unsubscribing contacts.

  • Bug Fixes
    • Render a confirmation screen on GET; no unsubscribe is executed during render.
    • Add POST server action to finalize unsubscribe; UnsubscribeButton uses useFormStatus for pending state.
    • Preserve the RFC 8058 one‑click POST endpoint and the re‑subscribe flow.
    • Validate link params and show clear, public-safe error messages.
    • Split verification and data access into getContactFromUnsubscribeLink and shared hash validation.
    • Add unit tests to ensure GET rendering never calls the unsubscribe mutation.

Written for commit 6ac4be1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a confirmation step before unsubscribing from email communications.
    • Displays the contact’s current subscription status and offers resubscription when already unsubscribed.
    • Added clear feedback for invalid links and unsubscribe errors.
    • Added a loading state while the unsubscribe request is processing.
  • Accessibility
    • Improved button states and touch-friendly interactions for unsubscribe and resubscribe actions.
  • Tests
    • Added coverage for valid and malformed unsubscribe links.

Entire-Checkpoint: 494da77ae083
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
unsend-marketingReadyReadyPreview, CommentJul 10, 2026 8:39pm

@coderabbitai

coderabbitaiBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The unsubscribe page no longer mutates contact state during GET requests. It validates signed link parameters, loads contact details, renders confirmation or resubscription states, and submits explicit unsubscribe requests through a server action. Errors are mapped to public messages and preserved through redirects. The campaign service centralizes link verification, and unit tests cover valid and malformed query parameters. UI controls now expose pending and disabled states.

Possibly related PRs

  • usesend/useSend#195: Uses the shared unsubscribe-link service behavior for the one-click unsubscribe endpoint.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly states the main behavioral change: requiring confirmation before campaign unsubscribe.
Linked Issues check✅ PassedGET now renders a confirmation UI and the unsubscribe mutation moved behind a POST server action, matching #412.
Out of Scope Changes check✅ PassedThe changes stay focused on unsubscribe flow safety, validation, UI, and regression tests with no clear unrelated additions.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

Latest commit:6ac4be1
Status: ✅ Deploy successful!
Preview URL:https://2faf64ab.usesend.pages.dev
Branch Preview URL:https://codex-fix-unsubscribe-get.usesend.pages.dev

View logs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

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

564-571: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use constant-time comparison for hash verification

hash !== expectedHash is vulnerable to timing attacks. While practical exploitation over HTTP is difficult, using crypto.timingSafeEqual is the standard practice for cryptographic hash comparison and eliminates the attack surface entirely.

🔐 Proposed fix
+import { createHash, timingSafeEqual } from "crypto";
// ... inside verifyUnsubscribeLink:
- if (hash !== expectedHash) {+ if (+ hash.length !== expectedHash.length ||+ !timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))+ ) {
throw new Error("Invalid unsubscribe link");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/server/service/campaign-service.ts` around lines 564 - 571,
Replace the direct comparison in the hash verification logic with
crypto.timingSafeEqual, comparing equal-length byte representations of hash and
expectedHash and handling length mismatches safely before comparison. Preserve
the existing Invalid unsubscribe link error behavior.
🧹 Nitpick comments (1)
apps/web/src/app/unsubscribe/page.unit.test.ts (1)

1-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for error and already-unsubscribed paths

The tests correctly verify the core PR objective (GET does not trigger unsubscribe mutation). Consider adding cases for:

  1. Error path: getContactFromUnsubscribeLink rejects (e.g., invalid hash or contact not found) → page should render MessageCard without calling unsubscribeContactFromLink.
  2. Already unsubscribed: mock returns subscribed: false → page should render ReSubscribe instead of the confirmation form.
🧪 Suggested additional tests
it("renders error card when contact lookup fails",async()=>{campaignService.getContactFromUnsubscribeLink.mockRejectedValueOnce(newError("Invalid unsubscribe link"),);constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "bad-hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});it("renders ReSubscribe when contact is already unsubscribed",async()=>{campaignService.getContactFromUnsubscribeLink.mockResolvedValueOnce({id: "contact-1",email: "person@example.com",subscribed: false,});constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/unsubscribe/page.unit.test.ts` around lines 1 - 45, Add test
coverage in the unsubscribe page test suite for both fallback branches: mock
getContactFromUnsubscribeLink to reject and assert the page renders without
calling unsubscribeContactFromLink, then mock it to return subscribed: false and
assert the already-unsubscribed ReSubscribe view renders without triggering the
mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 564-571: Replace the direct comparison in the hash verification
logic with crypto.timingSafeEqual, comparing equal-length byte representations
of hash and expectedHash and handling length mismatches safely before
comparison. Preserve the existing Invalid unsubscribe link error behavior.
---
Nitpick comments:
In `@apps/web/src/app/unsubscribe/page.unit.test.ts`:
- Around line 1-45: Add test coverage in the unsubscribe page test suite for
both fallback branches: mock getContactFromUnsubscribeLink to reject and assert
the page renders without calling unsubscribeContactFromLink, then mock it to
return subscribed: false and assert the already-unsubscribed ReSubscribe view
renders without triggering the mutation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 19477f38-b94f-444b-ab97-a63d050baee2

📥 Commits

Reviewing files that changed from the base of the PR and between 5259c7b and 6ac4be1.

📒 Files selected for processing (5)
  • apps/web/src/app/unsubscribe/page.tsx
  • apps/web/src/app/unsubscribe/page.unit.test.ts
  • apps/web/src/app/unsubscribe/re-subscribe.tsx
  • apps/web/src/app/unsubscribe/unsubscribe-button.tsx
  • apps/web/src/server/service/campaign-service.ts

@KMKoushik
KMKoushik merged commit 07dc83b into mainJul 10, 2026
5 checks passed
@KMKoushik
KMKoushik deleted the codex/fix-unsubscribe-get branch July 10, 2026 20:57
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.

🐞 - Campaign unsubscribe link unsubscribes on GET (link scanners cause false unsubscribes)

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: require confirmation before campaign unsubscribe - #416

Merged
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get
Jul 10, 2026
Merged

fix: require confirmation before campaign unsubscribe#416
KMKoushik merged 1 commit into
mainfrom
codex/fix-unsubscribe-get

Conversation

@KMKoushik

@KMKoushikKMKoushik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

  • make campaign unsubscribe links safe to open with GET
  • render a confirmation screen before changing subscription state
  • perform the unsubscribe through a POST-backed server action
  • preserve the existing RFC 8058 one-click POST endpoint and resubscribe flow
  • add regression coverage proving GET rendering never calls the unsubscribe mutation

Root cause

The in-body campaign unsubscribe page called unsubscribeContactFromLink while rendering a GET request. Email security scanners prefetch links, so they could unsubscribe contacts and increment campaign analytics without a recipient action. The signed hash could not prevent this because scanners received the complete signed URL.

Impact

Opening or scanning an in-body unsubscribe link is now read-only. Contacts are unsubscribed only after explicitly pressing Confirm unsubscribe.

Verification

  • pnpm --filter=web exec vitest run -c vitest.unit.config.ts src/app/unsubscribe/page.unit.test.ts — 2 tests passed
  • local Docker Postgres/Redis flow:
    • old GET: contact became unsubscribed and campaign counter incremented
    • fixed GET: contact remained subscribed and campaign counter stayed unchanged
    • confirmation POST: contact became unsubscribed and campaign counter incremented
  • git diff --check

Closes#412


Summary by cubic

Make campaign unsubscribe links safe to open via GET by adding a confirmation step and moving the unsubscribe to a POST-backed server action. Prevents email scanners from auto-unsubscribing contacts.

  • Bug Fixes
    • Render a confirmation screen on GET; no unsubscribe is executed during render.
    • Add POST server action to finalize unsubscribe; UnsubscribeButton uses useFormStatus for pending state.
    • Preserve the RFC 8058 one‑click POST endpoint and the re‑subscribe flow.
    • Validate link params and show clear, public-safe error messages.
    • Split verification and data access into getContactFromUnsubscribeLink and shared hash validation.
    • Add unit tests to ensure GET rendering never calls the unsubscribe mutation.

Written for commit 6ac4be1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added a confirmation step before unsubscribing from email communications.
    • Displays the contact’s current subscription status and offers resubscription when already unsubscribed.
    • Added clear feedback for invalid links and unsubscribe errors.
    • Added a loading state while the unsubscribe request is processing.
  • Accessibility
    • Improved button states and touch-friendly interactions for unsubscribe and resubscribe actions.
  • Tests
    • Added coverage for valid and malformed unsubscribe links.

Entire-Checkpoint: 494da77ae083
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
unsend-marketingReadyReadyPreview, CommentJul 10, 2026 8:39pm

@coderabbitai

coderabbitaiBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The unsubscribe page no longer mutates contact state during GET requests. It validates signed link parameters, loads contact details, renders confirmation or resubscription states, and submits explicit unsubscribe requests through a server action. Errors are mapped to public messages and preserved through redirects. The campaign service centralizes link verification, and unit tests cover valid and malformed query parameters. UI controls now expose pending and disabled states.

Possibly related PRs

  • usesend/useSend#195: Uses the shared unsubscribe-link service behavior for the one-click unsubscribe endpoint.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly states the main behavioral change: requiring confirmation before campaign unsubscribe.
Linked Issues check✅ PassedGET now renders a confirmation UI and the unsubscribe mutation moved behind a POST server action, matching #412.
Out of Scope Changes check✅ PassedThe changes stay focused on unsubscribe flow safety, validation, UI, and regression tests with no clear unrelated additions.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying usesend with Cloudflare Pages Cloudflare Pages

Latest commit:6ac4be1
Status: ✅ Deploy successful!
Preview URL:https://2faf64ab.usesend.pages.dev
Branch Preview URL:https://codex-fix-unsubscribe-get.usesend.pages.dev

View logs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

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

564-571: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use constant-time comparison for hash verification

hash !== expectedHash is vulnerable to timing attacks. While practical exploitation over HTTP is difficult, using crypto.timingSafeEqual is the standard practice for cryptographic hash comparison and eliminates the attack surface entirely.

🔐 Proposed fix
+import { createHash, timingSafeEqual } from "crypto";
// ... inside verifyUnsubscribeLink:
- if (hash !== expectedHash) {+ if (+ hash.length !== expectedHash.length ||+ !timingSafeEqual(Buffer.from(hash), Buffer.from(expectedHash))+ ) {
throw new Error("Invalid unsubscribe link");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/server/service/campaign-service.ts` around lines 564 - 571,
Replace the direct comparison in the hash verification logic with
crypto.timingSafeEqual, comparing equal-length byte representations of hash and
expectedHash and handling length mismatches safely before comparison. Preserve
the existing Invalid unsubscribe link error behavior.
🧹 Nitpick comments (1)
apps/web/src/app/unsubscribe/page.unit.test.ts (1)

1-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for error and already-unsubscribed paths

The tests correctly verify the core PR objective (GET does not trigger unsubscribe mutation). Consider adding cases for:

  1. Error path: getContactFromUnsubscribeLink rejects (e.g., invalid hash or contact not found) → page should render MessageCard without calling unsubscribeContactFromLink.
  2. Already unsubscribed: mock returns subscribed: false → page should render ReSubscribe instead of the confirmation form.
🧪 Suggested additional tests
it("renders error card when contact lookup fails",async()=>{campaignService.getContactFromUnsubscribeLink.mockRejectedValueOnce(newError("Invalid unsubscribe link"),);constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "bad-hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});it("renders ReSubscribe when contact is already unsubscribed",async()=>{campaignService.getContactFromUnsubscribeLink.mockResolvedValueOnce({id: "contact-1",email: "person@example.com",subscribed: false,});constpage=awaitUnsubscribePage({searchParams: Promise.resolve({id: "contact-campaign",hash: "hash"}),});expect(page).toBeTruthy();expect(campaignService.unsubscribeContactFromLink).not.toHaveBeenCalled();});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/app/unsubscribe/page.unit.test.ts` around lines 1 - 45, Add test
coverage in the unsubscribe page test suite for both fallback branches: mock
getContactFromUnsubscribeLink to reject and assert the page renders without
calling unsubscribeContactFromLink, then mock it to return subscribed: false and
assert the already-unsubscribed ReSubscribe view renders without triggering the
mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/web/src/server/service/campaign-service.ts`:
- Around line 564-571: Replace the direct comparison in the hash verification
logic with crypto.timingSafeEqual, comparing equal-length byte representations
of hash and expectedHash and handling length mismatches safely before
comparison. Preserve the existing Invalid unsubscribe link error behavior.
---
Nitpick comments:
In `@apps/web/src/app/unsubscribe/page.unit.test.ts`:
- Around line 1-45: Add test coverage in the unsubscribe page test suite for
both fallback branches: mock getContactFromUnsubscribeLink to reject and assert
the page renders without calling unsubscribeContactFromLink, then mock it to
return subscribed: false and assert the already-unsubscribed ReSubscribe view
renders without triggering the mutation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 19477f38-b94f-444b-ab97-a63d050baee2

📥 Commits

Reviewing files that changed from the base of the PR and between 5259c7b and 6ac4be1.

📒 Files selected for processing (5)
  • apps/web/src/app/unsubscribe/page.tsx
  • apps/web/src/app/unsubscribe/page.unit.test.ts
  • apps/web/src/app/unsubscribe/re-subscribe.tsx
  • apps/web/src/app/unsubscribe/unsubscribe-button.tsx
  • apps/web/src/server/service/campaign-service.ts

@KMKoushik
KMKoushik merged commit 07dc83b into mainJul 10, 2026
5 checks passed
@KMKoushik
KMKoushik deleted the codex/fix-unsubscribe-get branch July 10, 2026 20:57
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.

🐞 - Campaign unsubscribe link unsubscribes on GET (link scanners cause false unsubscribes)

1 participant

@KMKoushik