Skip to content

LO-030: Global Unsubscribe Domain List – BlockedDomain model, API, and campaign guard#306

Open
Anushreer22 wants to merge 3 commits into
Kuldeeep18:mainfrom
Anushreer22:feature/global-unsubscribe-domain-list
Open

LO-030: Global Unsubscribe Domain List – BlockedDomain model, API, and campaign guard#306
Anushreer22 wants to merge 3 commits into
Kuldeeep18:mainfrom
Anushreer22:feature/global-unsubscribe-domain-list

Conversation

@Anushreer22

@Anushreer22 Anushreer22 commented Jun 18, 2026

Copy link
Copy Markdown

Summary

Closes #209

This PR adds a global unsubscribe domain list, so that if an entire company domain is blocked, no email will be sent to any lead belonging to that domain within the user’s organisation.

Changes

1. Model

  • Created BlockedDomain model (leads/models.py)
    • Fields: domain, organization (FK to tenants.Organization), created_at
    • Unique together constraint on (domain, organization)

2. API Endpoints

  • Added BlockedDomainSerializer (leads/serializers.py)
  • Added BlockedDomainViewSet (leads/views.py)
    • All CRUD operations scoped to the authenticated user’s organisation
  • Registered routes at /leads/api/blocked-domains/

3. Campaign Sending Guard

  • Updated send_email_step task in campaigns/tasks.py
    • Before sending an email, the lead’s domain is extracted and checked against the organisation’s blocklist
    • If blocked, the lead is marked as SKIPPED and the task returns early

Testing

  • Migrations run cleanly
  • BlockedDomain API correctly scoped per organisation
  • Campaign emails skip leads from blocked domains

Files Modified

  • backend/leads/models.py
  • backend/leads/serializers.py
  • backend/leads/views.py
  • backend/leads/urls.py (created)
  • backend/LeadOrbit/urls.py
  • backend/campaigns/tasks.py
  • backend/leads/migrations/ (new migration)

Summary by CodeRabbit

  • New Features

    • Blocked domain validation is now performed during campaign email sending, ensuring emails to blocked domains are automatically skipped before standard processing workflows.
  • Improvements

    • Enhanced organization-level blocked domain configuration and management.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Four previously separate Django migrations are removed and replaced by a single consolidated migration. BlockedDomain is refactored from TenantModel to models.Model with an explicit organization FK and created_at, dropping domain validation methods. BlockedDomainSerializer swaps updated_at for organization. send_email_step gains an early blocked-domain skip guard before normal send logic.

Changes

BlockedDomain Refactor and Campaign Send Guard

Layer / File(s) Summary
BlockedDomain model and serializer
backend/leads/models.py, backend/leads/serializers.py
BlockedDomain switches from TenantModel to models.Model with an explicit organization FK and created_at field, removes clean()/save() validation, and updates unique_together and __str__. BlockedDomainSerializer replaces updated_at with organization as a read-only field.
Migration consolidation
backend/leads/migrations/0002_blockeddomain.py, backend/leads/migrations/0002_lead_custom_variables.py, backend/leads/migrations/0003_merge_...py, backend/leads/migrations/0004_leadimportjob.py, backend/leads/migrations/0004_tag_color.py, backend/leads/migrations/0005_merge_...py, backend/leads/migrations/0002_lead_custom_variables_tag_color_leadimportjob_and_more.py
Six split migration files are deleted and replaced by a single consolidated migration that creates LeadImportJob, BlockedDomain, adds lead.custom_variables (JSONField), and adds tag.color (CharField, default #6366f1).
Blocked domain guard in send_email_step
backend/campaigns/tasks.py
Adds an early check in send_email_step that queries BlockedDomain for the lead's email domain scoped to the campaign's organization; if matched, the lead is set to SKIPPED and the task returns before any unsubscribe or send routing. Removes normalize_domain from the import while calls to it remain.

Sequence Diagram(s)

sequenceDiagram
  participant Celery as Celery Worker
  participant Task as send_email_step
  participant DB as BlockedDomain (DB)
  participant Lead as Lead Model

  Celery->>Task: execute(lead_id, campaign_id)
  Task->>Task: extract domain from lead.email
  Task->>DB: filter(domain=domain, organization=campaign.org)
  alt Domain is blocked
    DB-->>Task: record found
    Task->>Lead: status = SKIPPED, save()
    Task-->>Celery: return (skipped)
  else Domain not blocked
    DB-->>Task: no record
    Task->>Task: proceed with global_unsubscribe check
    Task->>Task: send / advance step logic
    Task-->>Celery: return (sent or advanced)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 Hop hop, the domain list grows,
Six migrations squished to one tidy row,
TenantModel shed, explicit FK shows,
Blocked domains caught before emails flow,
The rabbit keeps the inbox clean — just so! ✉️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Out of Scope Changes check ❓ Inconclusive The PR includes migration consolidation (combining 0002-0005 migrations into 0002_lead_custom_variables_tag_color_leadimportjob_and_more) which appears necessary for the refactored BlockedDomain model but extends beyond the core domain-blocklist feature scope. Clarify whether migration consolidation is a prerequisite for this PR or should be handled in a separate refactoring change.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main work: implementing a global unsubscribe domain list feature with BlockedDomain model, API, and campaign guard.
Linked Issues check ✅ Passed The PR implements all three core requirements from issue #209: BlockedDomain model with organization scoping, CRUD API via BlockedDomainViewSet, and early domain-check guard in send_email_step marking leads as SKIPPED.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 (2)
backend/leads/serializers.py (1)

79-112: ⚠️ Potential issue | 🔴 Critical

Remove the duplicate serializer class definition and merge field/read_only changes into the existing one.

Lines 108–112 redefine BlockedDomainSerializer, causing Python to discard the first definition (lines 79–106) entirely. This drops validate_domain() and the duplicate-domain validation check. Without validate_domain(), the API persists raw domains like HTTPS://Competitor.COM/path, while the domain lookup (and any campaign guards) normalize them first, creating a mismatch that can bypass filters.

Suggested fix
 class BlockedDomainSerializer(serializers.ModelSerializer):
     class Meta:
         model = BlockedDomain
-        fields = ['id', 'domain', 'created_at', 'updated_at']
-        read_only_fields = ['id', 'created_at', 'updated_at']
+        fields = ['id', 'domain', 'organization', 'created_at']
+        read_only_fields = ['id', 'organization', 'created_at']

     def validate_domain(self, value):
         try:
             return validate_domain(value)
         except DjangoValidationError as exc:
             raise serializers.ValidationError(exc.messages)

     def validate(self, attrs):
         request = self.context.get('request')
         organization = getattr(getattr(request, 'user', None), 'organization', None)
         domain = attrs.get('domain', getattr(self.instance, 'domain', None))

         if organization and domain:
             existing = BlockedDomain.objects.filter(
                 organization=organization,
                 domain=domain,
             )
             if self.instance:
                 existing = existing.exclude(id=self.instance.id)
             if existing.exists():
                 raise serializers.ValidationError({'domain': 'This domain is already blocked.'})

         return attrs
-
-
-class BlockedDomainSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = BlockedDomain
-        fields = ['id', 'domain', 'organization', 'created_at']
-        read_only_fields = ['id', 'organization', 'created_at']
🤖 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/leads/serializers.py` around lines 79 - 112, The
BlockedDomainSerializer class is defined twice, with the second definition
overwriting the first and discarding all validation logic. Merge the two class
definitions by keeping the first BlockedDomainSerializer (which contains
validate_domain and validate methods) and update its Meta class fields to
include 'organization' and adjust the read_only_fields list to match the second
definition. Delete the duplicate BlockedDomainSerializer class definition that
appears after the first one, ensuring all validation methods (validate_domain
and validate for duplicate checks) are preserved in the final single class
definition.
backend/leads/models.py (1)

80-110: ⚠️ Potential issue | 🟠 Major

Remove the duplicate BlockedDomain class definition and consolidate to a single model.

The first BlockedDomain(TenantModel) definition (lines 80–95) is shadowed by the second class BlockedDomain(models.Model) definition (lines 96–110). Django registers both classes during module import, leaving stale model-registration state. The second definition also loses the domain validation logic (clean and save methods) from the first. Keep the second definition with explicit organization FK but restore the validation methods.

Suggested consolidation
-class BlockedDomain(TenantModel):
-    domain = models.CharField(max_length=255)
-
-    class Meta:
-        unique_together = ('organization', 'domain')
-        ordering = ['domain']
-
-    def clean(self):
-        self.domain = validate_domain(self.domain)
-
-    def save(self, *args, **kwargs):
-        self.domain = validate_domain(self.domain)
-        super().save(*args, **kwargs)
-
-    def __str__(self):
-        return self.domain
 class BlockedDomain(models.Model):
     domain = models.CharField(max_length=255)
     organization = models.ForeignKey(
-        'tenants.Organization',   # adjust if path differs
+        'tenants.Organization',
         on_delete=models.CASCADE,
         related_name='blocked_domains'
     )
     created_at = models.DateTimeField(auto_now_add=True)

     class Meta:
         unique_together = ('domain', 'organization')
         ordering = ['domain']
+
+    def clean(self):
+        self.domain = validate_domain(self.domain)
+
+    def save(self, *args, **kwargs):
+        self.domain = validate_domain(self.domain)
+        super().save(*args, **kwargs)

     def __str__(self):
         return f"{self.domain} (Org: {self.organization_id})"
🤖 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/leads/models.py` around lines 80 - 110, Remove the first
BlockedDomain class definition that inherits from TenantModel (the one without
the explicit organization FK) to eliminate the duplicate class definition that
shadows the second one. Keep the second BlockedDomain class that inherits from
models.Model and explicitly defines the organization ForeignKey and created_at
fields, but restore the domain validation logic by adding back the clean()
method and save() method from the first definition that call validate_domain()
on the domain field to ensure the validation is preserved in the consolidated
model.
🤖 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/tasks.py`:
- Around line 434-444: Remove the dedented global unsubscribe domain check block
that appears after the try body at lines 434-444. This block is causing a
SyntaxError because it's at function level immediately after try without an
except or finally clause. Instead of this duplicate inline logic, use the
existing _skip_blocked_domain_lead() helper function which properly handles all
necessary cleanup including clearing current_step, next_execution_time, and
marking campaign completion.

---

Outside diff comments:
In `@backend/leads/models.py`:
- Around line 80-110: Remove the first BlockedDomain class definition that
inherits from TenantModel (the one without the explicit organization FK) to
eliminate the duplicate class definition that shadows the second one. Keep the
second BlockedDomain class that inherits from models.Model and explicitly
defines the organization ForeignKey and created_at fields, but restore the
domain validation logic by adding back the clean() method and save() method from
the first definition that call validate_domain() on the domain field to ensure
the validation is preserved in the consolidated model.

In `@backend/leads/serializers.py`:
- Around line 79-112: The BlockedDomainSerializer class is defined twice, with
the second definition overwriting the first and discarding all validation logic.
Merge the two class definitions by keeping the first BlockedDomainSerializer
(which contains validate_domain and validate methods) and update its Meta class
fields to include 'organization' and adjust the read_only_fields list to match
the second definition. Delete the duplicate BlockedDomainSerializer class
definition that appears after the first one, ensuring all validation methods
(validate_domain and validate for duplicate checks) are preserved in the final
single class definition.
🪄 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: 3339eee0-e02f-4404-afaa-a6290e087253

📥 Commits

Reviewing files that changed from the base of the PR and between 90052f9 and 0d63d6a.

⛔ Files ignored due to path filters (2)
  • backend/leads/migrations/__pycache__/0001_initial.cpython-314.pyc is excluded by !**/*.pyc
  • backend/leads/migrations/__pycache__/__init__.cpython-314.pyc is excluded by !**/*.pyc
📒 Files selected for processing (10)
  • backend/campaigns/tasks.py
  • backend/leads/migrations/0002_blockeddomain.py
  • backend/leads/migrations/0002_lead_custom_variables.py
  • backend/leads/migrations/0002_lead_custom_variables_tag_color_leadimportjob_and_more.py
  • backend/leads/migrations/0003_merge_0002_blockeddomain_0002_lead_custom_variables.py
  • backend/leads/migrations/0004_leadimportjob.py
  • backend/leads/migrations/0004_tag_color.py
  • backend/leads/migrations/0005_merge_0004_leadimportjob_0004_tag_color.py
  • backend/leads/models.py
  • backend/leads/serializers.py
💤 Files with no reviewable changes (6)
  • backend/leads/migrations/0003_merge_0002_blockeddomain_0002_lead_custom_variables.py
  • backend/leads/migrations/0005_merge_0004_leadimportjob_0004_tag_color.py
  • backend/leads/migrations/0002_lead_custom_variables.py
  • backend/leads/migrations/0004_leadimportjob.py
  • backend/leads/migrations/0004_tag_color.py
  • backend/leads/migrations/0002_blockeddomain.py

Comment on lines +434 to +444
# --- Global unsubscribe domain check ---
domain = clead.lead.email.split('@')[-1].lower()
if BlockedDomain.objects.filter(
domain=domain,
organization=clead.campaign.organization
).exists():
clead.status = 'SKIPPED'
clead.save(update_fields=['status'])
logger.info(f"Skipping lead {clead.lead.email}: domain {domain} is blocked")
return
# --- end domain check ---

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
from pathlib import Path
import ast

path = Path("backend/campaigns/tasks.py")
ast.parse(path.read_text(), filename=str(path))
print("syntax ok")
PY

Repository: Kuldeeep18/LeadOrbit

Length of output: 452


🏁 Script executed:

cd backend/campaigns && head -n 450 tasks.py | tail -n 40 | cat -n

Repository: Kuldeeep18/LeadOrbit

Length of output: 1794


🏁 Script executed:

rg "_skip_blocked_domain_lead" backend/campaigns/tasks.py -A 10

Repository: Kuldeeep18/LeadOrbit

Length of output: 945


Remove the dedented blocked-domain block at lines 434-444.

The module fails to parse with SyntaxError: expected 'except' or 'finally' block because this block is dedented to function level immediately after the try body (line 419). This duplicate code also bypasses the existing _skip_blocked_domain_lead() helper, which properly clears current_step, next_execution_time, and marks campaign completion—none of which happen in this inline version.

Suggested fix
-    # --- Global unsubscribe domain check ---
-    domain = clead.lead.email.split('@')[-1].lower()
-    if BlockedDomain.objects.filter(
-        domain=domain,
-        organization=clead.campaign.organization
-    ).exists():
-        clead.status = 'SKIPPED'
-        clead.save(update_fields=['status'])
-        logger.info(f"Skipping lead {clead.lead.email}: domain {domain} is blocked")
-        return
-    # --- end domain check ---
-
📝 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
# --- Global unsubscribe domain check ---
domain = clead.lead.email.split('@')[-1].lower()
if BlockedDomain.objects.filter(
domain=domain,
organization=clead.campaign.organization
).exists():
clead.status = 'SKIPPED'
clead.save(update_fields=['status'])
logger.info(f"Skipping lead {clead.lead.email}: domain {domain} is blocked")
return
# --- end domain check ---
🧰 Tools
🪛 Ruff (0.15.17)

[warning] 435-435: Expected except or finally after try block

(invalid-syntax)

🤖 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/tasks.py` around lines 434 - 444, Remove the dedented
global unsubscribe domain check block that appears after the try body at lines
434-444. This block is causing a SyntaxError because it's at function level
immediately after try without an except or finally clause. Instead of this
duplicate inline logic, use the existing _skip_blocked_domain_lead() helper
function which properly handles all necessary cleanup including clearing
current_step, next_execution_time, and marking campaign completion.

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-030 [Medium]: Global Unsubscribe Domain List

1 participant