Skip to content

feat: add unsubscribe feedback survey (#560)#631

Open
karuna-1 wants to merge 3 commits into
Kuldeeep18:mainfrom
karuna-1:feat/issue-560-unsubscribe-feedback
Open

feat: add unsubscribe feedback survey (#560)#631
karuna-1 wants to merge 3 commits into
Kuldeeep18:mainfrom
karuna-1:feat/issue-560-unsubscribe-feedback

Conversation

@karuna-1

@karuna-1 karuna-1 commented Jul 8, 2026

Copy link
Copy Markdown

Pull Request

🔗 Related Issue

Closes #560


📝 Summary of Changes

This PR improves the email unsubscribe experience by adding an optional feedback survey to the confirmation page. Users can select one or more reasons for unsubscribing, and their responses are stored in the database to provide insights that can help improve future email campaigns.

Changes made:

  • Added a new UnsubscribeFeedback model to store unsubscribe feedback.

  • Updated the unsubscribe confirmation page to display optional checkbox-based feedback.

  • Saved the selected feedback reasons when users confirm their unsubscribe request.

  • Added tests to verify that:

    • The feedback options are displayed on the unsubscribe page.
    • Feedback is stored successfully after a user unsubscribes.

🏷️ Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • ♻️ Refactor
  • 📝 Documentation update
  • 🎨 UI / Style change
  • 🔧 Chore

🧪 Testing

Verified the implementation by running the relevant unsubscribe tests.

Steps to test:

  1. Open the unsubscribe link for a valid lead.

  2. Verify that the feedback survey with checkbox options is displayed.

  3. Select one or more reasons and confirm the unsubscribe.

  4. Verify that the lead is unsubscribed and the selected feedback is stored in the database.

  5. Run:

    • python manage.py test campaigns.tests.CampaignWorkflowTests.test_unsubscribe_get_shows_confirmation_without_updating_lead
    • python manage.py test campaigns.tests.CampaignWorkflowTests.test_unsubscribe_post_marks_lead_unsubscribed

📸 Screenshots (if applicable)

N/A (Backend functionality with a simple HTML form update.)


✅ Checklist

  • No merge conflicts
  • Changes follow the project guidelines
  • Documentation updated (not required)
  • Related issue linked
  • Changes tested locally

Summary by CodeRabbit

  • New Features

    • Added optional unsubscribe feedback choices so people can share why they’re leaving.
    • Added click-through handling for tracked links, including validation and safer redirect behavior.
  • Bug Fixes

    • Unsubscribe actions now save the selected feedback details along with the unsubscribe status.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@karuna-1, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 85aa0433-e02f-4136-95cb-2471cf85bda6

📥 Commits

Reviewing files that changed from the base of the PR and between fa95e54 and a4a7c3e.

📒 Files selected for processing (1)
  • backend/campaigns/views.py
📝 Walkthrough

Walkthrough

Adds an unsubscribe feedback model and migration, updates the unsubscribe page to collect and save selected reasons, refreshes related tests, and introduces a click-tracking endpoint that validates signed links and redirects.

Changes

Unsubscribe Feedback Survey

Layer / File(s) Summary
UnsubscribeFeedback model and migration
backend/campaigns/models.py, backend/campaigns/migrations/0011_unsubscribefeedback.py
Adds UnsubscribeFeedback with lead and organization foreign keys, a JSON reasons field, and the matching table migration.
Unsubscribe view form and persistence
backend/campaigns/views.py
Imports UnsubscribeFeedback, renders reason checkboxes on the unsubscribe page, and saves submitted reasons on POST.
Unsubscribe tests updated for feedback
backend/campaigns/tests.py
Updates the unsubscribe tests to check the new reason options and persisted feedback records.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Click Tracking Endpoint

Layer / File(s) Summary
ClickTrackingView implementation
backend/campaigns/views.py
Adds ClickTrackingView for signed token validation, click timestamp updates, optional condition-step execution, and redirect handling.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Prospect
  participant UnsubscribeView
  participant UnsubscribeFeedback

  Prospect->>UnsubscribeView: GET unsubscribe page
  UnsubscribeView-->>Prospect: confirmation form with reasons
  Prospect->>UnsubscribeView: POST selected reasons
  UnsubscribeView->>UnsubscribeFeedback: save feedback record
  UnsubscribeView-->>Prospect: unsubscribe response
Loading
sequenceDiagram
  participant Recipient
  participant ClickTrackingView
  participant CampaignLead
  participant RedirectTarget

  Recipient->>ClickTrackingView: GET tracking link
  ClickTrackingView->>ClickTrackingView: verify token and destination
  ClickTrackingView->>CampaignLead: update last_clicked_at
  ClickTrackingView->>CampaignLead: run CONDITION_CLICK step if needed
  ClickTrackingView-->>RedirectTarget: redirect
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also adds ClickTrackingView for issue #259, which is unrelated to the unsubscribe feedback survey. Move the ClickTrackingView changes into a separate PR or explicitly include issue #259 in scope.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding an unsubscribe feedback survey.
Linked Issues check ✅ Passed The unsubscribe confirmation survey, storage model, and persistence logic all match issue #560's requirements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
backend/campaigns/views.py (1)

793-830: 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Sign the redirect target before using it

  • dest is only a query parameter, so any valid tracking link can be repointed to an arbitrary URL and turned into an open redirect.
  • Unexpected errors in the analytics / _execute_condition_click_step path can still abort the request before the redirect; keep that path from failing the click-through.
🤖 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 `@backend/campaigns/views.py` around lines 793 - 830, The ClickTrackingView.get
flow uses the raw dest query parameter as the redirect target, which allows
repointing to arbitrary URLs and creates an open redirect. Update
ClickTrackingView.get to verify or sign the destination before redirecting, and
only redirect after validating that the decoded_dest matches the expected signed
target. Also make the analytics and _execute_condition_click_step path fully
non-fatal by catching unexpected exceptions around the CampaignLead update so
click-through still proceeds to HttpResponseRedirect even if tracking logic
fails.
🧹 Nitpick comments (2)
backend/campaigns/models.py (1)

152-161: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider adding a uniqueness constraint on lead to prevent duplicate feedback records.

The view creates a new UnsubscribeFeedback on every POST without checking for an existing record. Since unsubscribed leads shouldn't receive further emails, one feedback per lead is the expected cardinality — but the model's ForeignKey allows multiples. The test at tests.py:1745 uses .get(lead=lead), which would raise MultipleObjectsReturned if duplicates exist (e.g., from a double-click or replayed POST).

♻️ Proposed fix: add unique constraint
 class UnsubscribeFeedback(TenantModel):
     lead = models.ForeignKey(
         Lead,
         on_delete=models.CASCADE,
         related_name="unsubscribe_feedbacks",
     )
     reasons = models.JSONField(default=list, blank=True)

+    class Meta:
+        ordering = ['-created_at']
+
     def __str__(self):
         return f"Feedback for {self.lead.email}"

Then update the view to use update_or_create instead of create (see the view review comment for the corresponding fix).

🤖 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 `@backend/campaigns/models.py` around lines 152 - 161, Add a uniqueness
constraint to UnsubscribeFeedback so each Lead can have only one feedback
record; the current ForeignKey on lead allows duplicates and breaks callers like
the test using .get(lead=lead). Update the UnsubscribeFeedback model to enforce
one-to-one cardinality for lead, or add an equivalent unique constraint, and
then make sure the unsubscribe view that creates feedback uses update_or_create
instead of always creating a new row.
backend/campaigns/tests.py (1)

1730-1751: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test case for POST with no reasons selected.

The if reasons: guard in the view means no UnsubscribeFeedback record is created when no checkboxes are selected, but this path is not tested. A missing test leaves the "no feedback created" behavior unverified.

🧪 Proposed additional test
def test_unsubscribe_post_without_reasons_does_not_create_feedback(self):
    lead = Lead.objects.create(
        organization=self.organization,
        email='no-reasons@acme.test',
    )
    token = generate_unsubscribe_token(lead.id)

    response = self.client.post(
        f'/api/v1/unsubscribe/{lead.id}/{token}/',
    )
    self.assertEqual(response.status_code, status.HTTP_200_OK)
    self.assertIn('You have been unsubscribed', response.content.decode('utf-8'))

    lead.refresh_from_db()
    self.assertTrue(lead.global_unsubscribe)
    self.assertFalse(UnsubscribeFeedback.objects.filter(lead=lead).exists())
🤖 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 `@backend/campaigns/tests.py` around lines 1730 - 1751, Add a test in the
unsubscribe test class to cover POST requests made without any reasons selected,
using the same unsubscribe endpoint and token flow as the existing unsubscribe
test. Verify the response still returns success and the lead becomes globally
unsubscribed, then assert that
UnsubscribeFeedback.objects.filter(lead=lead).exists() is false so the
no-feedback path in the view’s if reasons guard is exercised. Keep the new test
alongside the existing unsubscribe POST test that currently checks
UnsubscribeFeedback.objects.get(lead=lead) when reasons are provided.
🤖 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.

Inline comments:
In `@backend/campaigns/views.py`:
- Around line 775-783: The unsubscribe feedback creation in the view is not
idempotent and accepts unvalidated reason values. Update the `reasons` handling
in the unsubscribe flow to validate incoming values against the allowed form
choices before saving, then replace the plain
`UnsubscribeFeedback.objects.create(...)` call with an idempotent
`update_or_create`-style write keyed by `lead` (and `organization` if needed) so
repeated POSTs reuse the same `UnsubscribeFeedback` record instead of creating
duplicates.

---

Outside diff comments:
In `@backend/campaigns/views.py`:
- Around line 793-830: The ClickTrackingView.get flow uses the raw dest query
parameter as the redirect target, which allows repointing to arbitrary URLs and
creates an open redirect. Update ClickTrackingView.get to verify or sign the
destination before redirecting, and only redirect after validating that the
decoded_dest matches the expected signed target. Also make the analytics and
_execute_condition_click_step path fully non-fatal by catching unexpected
exceptions around the CampaignLead update so click-through still proceeds to
HttpResponseRedirect even if tracking logic fails.

---

Nitpick comments:
In `@backend/campaigns/models.py`:
- Around line 152-161: Add a uniqueness constraint to UnsubscribeFeedback so
each Lead can have only one feedback record; the current ForeignKey on lead
allows duplicates and breaks callers like the test using .get(lead=lead). Update
the UnsubscribeFeedback model to enforce one-to-one cardinality for lead, or add
an equivalent unique constraint, and then make sure the unsubscribe view that
creates feedback uses update_or_create instead of always creating a new row.

In `@backend/campaigns/tests.py`:
- Around line 1730-1751: Add a test in the unsubscribe test class to cover POST
requests made without any reasons selected, using the same unsubscribe endpoint
and token flow as the existing unsubscribe test. Verify the response still
returns success and the lead becomes globally unsubscribed, then assert that
UnsubscribeFeedback.objects.filter(lead=lead).exists() is false so the
no-feedback path in the view’s if reasons guard is exercised. Keep the new test
alongside the existing unsubscribe POST test that currently checks
UnsubscribeFeedback.objects.get(lead=lead) when reasons are provided.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 114ba323-124a-491f-954c-a35ff3a964b7

📥 Commits

Reviewing files that changed from the base of the PR and between 4a33158 and 0fbae4f.

📒 Files selected for processing (4)
  • backend/campaigns/migrations/0011_unsubscribefeedback.py
  • backend/campaigns/models.py
  • backend/campaigns/tests.py
  • backend/campaigns/views.py

Comment thread backend/campaigns/views.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
backend/campaigns/views.py (1)

808-821: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Open redirect: dest is still attacker-controlled
dest_url comes straight from the query string, is only unquoted, and then gets passed to HttpResponseRedirect. A valid t can be reused with &dest=https://evil.com, so this endpoint can redirect to arbitrary sites. Bind dest into the signed payload or restrict redirects to an allowlist of internal targets.

🤖 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 `@backend/campaigns/views.py` around lines 808 - 821, The tracking redirect in
the get handler still trusts the attacker-controlled dest query parameter, so
fix it by removing raw dest_url usage and binding the destination into the
signed payload verified by Signer.unsign in this view. Update the token handling
around signed_token/campaign_lead_id/step_id so the redirect target is validated
from the signed data or restricted to an internal allowlist before
HttpResponseRedirect is called.
🤖 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.

Inline comments:
In `@backend/campaigns/views.py`:
- Around line 775-791: The unsubscribe feedback handling block is accidentally
dedented out of `unsubscribe_view`, so it runs at module import time instead of
during the request. Re-indent the `ALLOWED_REASONS` filtering and
`UnsubscribeFeedback.objects.update_or_create` logic back inside
`unsubscribe_view` so `request` and `lead` are in scope and the module can
import cleanly.

---

Outside diff comments:
In `@backend/campaigns/views.py`:
- Around line 808-821: The tracking redirect in the get handler still trusts the
attacker-controlled dest query parameter, so fix it by removing raw dest_url
usage and binding the destination into the signed payload verified by
Signer.unsign in this view. Update the token handling around
signed_token/campaign_lead_id/step_id so the redirect target is validated from
the signed data or restricted to an internal allowlist before
HttpResponseRedirect is called.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 38e8e45f-a4bb-4b9b-9c5e-727dfdaf5b2b

📥 Commits

Reviewing files that changed from the base of the PR and between 0fbae4f and fa95e54.

📒 Files selected for processing (1)
  • backend/campaigns/views.py

Comment thread backend/campaigns/views.py Outdated
Comment on lines +775 to +791
ALLOWED_REASONS = {
"Too frequent emails",
"No longer relevant",
"Never signed up",
}

reasons = request.POST.getlist("reasons")
valid_reasons = [reason for reason in reasons if reason in ALLOWED_REASONS]

if valid_reasons:
UnsubscribeFeedback.objects.update_or_create(
lead=lead,
defaults={
"organization": lead.organization,
"reasons": valid_reasons,
},
)

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.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Critical: block is dedented out of unsubscribe_view, breaking module import.

Lines 781–791 sit at module scope (column 0), so request, lead, and ALLOWED_REASONS resolve at import time and raise NameError — the entire views.py module fails to load. This matches the Ruff F821 errors. The feedback-saving logic also never runs inside the request path. Re-indent the whole block into the function body.

🐛 Proposed fix: re-indent into the function
-    ALLOWED_REASONS = {
-    "Too frequent emails",
-    "No longer relevant",
-    "Never signed up",
-}
-
-reasons = request.POST.getlist("reasons")
-valid_reasons = [reason for reason in reasons if reason in ALLOWED_REASONS]
-
-if valid_reasons:
-    UnsubscribeFeedback.objects.update_or_create(
-        lead=lead,
-        defaults={
-            "organization": lead.organization,
-            "reasons": valid_reasons,
-        },
-    )
+    ALLOWED_REASONS = {
+        "Too frequent emails",
+        "No longer relevant",
+        "Never signed up",
+    }
+
+    reasons = request.POST.getlist("reasons")
+    valid_reasons = [reason for reason in reasons if reason in ALLOWED_REASONS]
+
+    if valid_reasons:
+        UnsubscribeFeedback.objects.update_or_create(
+            lead=lead,
+            defaults={
+                "organization": lead.organization,
+                "reasons": valid_reasons,
+            },
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ALLOWED_REASONS = {
"Too frequent emails",
"No longer relevant",
"Never signed up",
}
reasons = request.POST.getlist("reasons")
valid_reasons = [reason for reason in reasons if reason in ALLOWED_REASONS]
if valid_reasons:
UnsubscribeFeedback.objects.update_or_create(
lead=lead,
defaults={
"organization": lead.organization,
"reasons": valid_reasons,
},
)
ALLOWED_REASONS = {
"Too frequent emails",
"No longer relevant",
"Never signed up",
}
reasons = request.POST.getlist("reasons")
valid_reasons = [reason for reason in reasons if reason in ALLOWED_REASONS]
if valid_reasons:
UnsubscribeFeedback.objects.update_or_create(
lead=lead,
defaults={
"organization": lead.organization,
"reasons": valid_reasons,
},
)
🧰 Tools
🪛 Ruff (0.15.20)

[error] 781-781: Undefined name request

(F821)


[error] 782-782: Undefined name ALLOWED_REASONS

(F821)


[error] 786-786: Undefined name lead

(F821)


[error] 788-788: Undefined name lead

(F821)

🤖 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 `@backend/campaigns/views.py` around lines 775 - 791, The unsubscribe feedback
handling block is accidentally dedented out of `unsubscribe_view`, so it runs at
module import time instead of during the request. Re-indent the
`ALLOWED_REASONS` filtering and `UnsubscribeFeedback.objects.update_or_create`
logic back inside `unsubscribe_view` so `request` and `lead` are in scope and
the module can import cleanly.

Source: Linters/SAST tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LO-123 [Easy]: Confirm Email Unsubscribe with a Simple Feedback Survey

1 participant