diff --git a/backend/campaigns/migrations/0010_merge_0009_campaign_cached_counters_0009_campaignlead_bounce_metadata.py b/backend/campaigns/migrations/0010_merge_0009_campaign_cached_counters_0009_campaignlead_bounce_metadata.py new file mode 100644 index 0000000..317a41b --- /dev/null +++ b/backend/campaigns/migrations/0010_merge_0009_campaign_cached_counters_0009_campaignlead_bounce_metadata.py @@ -0,0 +1,11 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('campaigns', '0009_campaign_cached_counters'), + ('campaigns', '0009_campaignlead_bounce_metadata'), + ] + + operations = [] diff --git a/backend/campaigns/tasks.py b/backend/campaigns/tasks.py index de16c24..65b9b1e 100644 --- a/backend/campaigns/tasks.py +++ b/backend/campaigns/tasks.py @@ -1,18 +1,18 @@ -import logging from datetime -import timedelta from celery -import shared_task from django.conf -import settings as django_settings from django.utils -import timezone from .ai -import _apply_merge_tags, personalize_email from .gmail_service -import build_unsubscribe_url, check_for_replies, send_gmail from .sms_service -import send_sms, initiate_call from .models -import CampaignLead, SequenceStep from leads.models -import BlockedDomain, normalize_domain - - +import logging import urllib.parse +from datetime import timedelta + from bs4 import BeautifulSoup +from celery import shared_task +from django.conf import settings as django_settings from django.core.signing import Signer +from django.utils import timezone + +from .ai import _apply_merge_tags, personalize_email +from .gmail_service import build_unsubscribe_url, check_for_replies, send_gmail +from .models import CampaignLead, SequenceStep +from .sms_service import send_sms, initiate_call +from leads.models import BlockedDomain, normalize_domain logger = logging.getLogger(__name__) @@ -692,4 +692,4 @@ def poll_gmail_for_replies(): logger.info(f"Reply detected for {clead.lead.email} in campaign {clead.campaign.name}") _maybe_mark_campaign_completed(clead.campaign) - return f"Detected {total_replies} new replies." \ No newline at end of file + return f"Detected {total_replies} new replies." diff --git a/backend/campaigns/tests.py b/backend/campaigns/tests.py index 1d96075..7759c02 100644 --- a/backend/campaigns/tests.py +++ b/backend/campaigns/tests.py @@ -1319,6 +1319,27 @@ def test_unsubscribe_get_shows_confirmation_without_updating_lead(self): lead.refresh_from_db() self.assertFalse(lead.global_unsubscribe) + def test_unsubscribe_get_uses_organization_branding_when_available(self): + self.organization.unsubscribe_title = 'Stay in touch' + self.organization.unsubscribe_message = 'We will miss you, but you can leave anytime.' + self.organization.brand_logo_url = 'https://cdn.example.test/logo.png' + self.organization.save(update_fields=['unsubscribe_title', 'unsubscribe_message', 'brand_logo_url']) + + lead = Lead.objects.create( + organization=self.organization, + email='branded@acme.test', + ) + token = generate_unsubscribe_token(lead.id) + + response = self.client.get(f'/api/v1/unsubscribe/{lead.id}/{token}/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + html = response.content.decode('utf-8') + self.assertIn('Stay in touch', html) + self.assertIn('We will miss you, but you can leave anytime.', html) + self.assertIn('https://cdn.example.test/logo.png', html) + self.assertIn('brand-logo', html) + def test_unsubscribe_post_marks_lead_unsubscribed(self): lead = Lead.objects.create( organization=self.organization, diff --git a/backend/campaigns/views.py b/backend/campaigns/views.py index 2a11bf0..1f39861 100644 --- a/backend/campaigns/views.py +++ b/backend/campaigns/views.py @@ -2,6 +2,7 @@ import urllib.parse +from html import escape from django.core.signing import Signer, BadSignature from django.http import HttpResponseRedirect, HttpResponseBadRequest # ------------------------------------------ @@ -719,20 +720,23 @@ def _build_fallback_content(self, request): def _unsubscribe_page(title, message, extra_html=''): + safe_title = escape(title) + safe_message = escape(message) return ( '' '' '
' '' '' - f'{message}
{extra_html}{safe_message}
If you received this link by mistake, no further action is needed.
', + organization.unsubscribe_title or 'Unsubscribed', + organization.unsubscribe_message + or 'You have been unsubscribed from all future emails sent through LeadOrbit.', + brand_logo_html + 'If you received this link by mistake, no further action is needed.
', ) return HttpResponse(html, content_type='text/html') @@ -820,4 +835,4 @@ def get(self, request, *args, **kwargs): # Original Destination par redirect karna decoded_dest = urllib.parse.unquote(dest_url) return HttpResponseRedirect(decoded_dest) -# ------------------------------------------ \ No newline at end of file +# ------------------------------------------ diff --git a/backend/tenants/migrations/0003_add_unsubscribe_branding.py b/backend/tenants/migrations/0003_add_unsubscribe_branding.py new file mode 100644 index 0000000..8228ea9 --- /dev/null +++ b/backend/tenants/migrations/0003_add_unsubscribe_branding.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.30 on 2026-06-22 06:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenants', '0002_organization_enable_ai_personalization_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='brand_logo_url', + field=models.URLField(blank=True, null=True), + ), + migrations.AddField( + model_name='organization', + name='unsubscribe_message', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='organization', + name='unsubscribe_title', + field=models.CharField(blank=True, max_length=120, null=True), + ), + ] diff --git a/backend/tenants/models.py b/backend/tenants/models.py index bb58b37..3e1f772 100644 --- a/backend/tenants/models.py +++ b/backend/tenants/models.py @@ -9,6 +9,9 @@ class Organization(models.Model): created_at = models.DateTimeField(auto_now_add=True) gemini_api_key = models.CharField(max_length=255, blank=True, null=True) enable_ai_personalization = models.BooleanField(default=True) + unsubscribe_title = models.CharField(max_length=120, blank=True, null=True) + unsubscribe_message = models.TextField(blank=True, null=True) + brand_logo_url = models.URLField(blank=True, null=True) def __str__(self): return self.name