Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 28 additions & 25 deletions backend/campaigns/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

logger = logging.getLogger(__name__)


def _get_campaign_steps(campaign):
"""
Returns ordered steps for a campaign.
Expand All @@ -41,6 +42,7 @@ def _get_campaign_steps(campaign):
).order_by("step_order")
)


def _get_campaign_raw_steps(campaign):
settings = campaign.settings if isinstance(campaign.settings, dict) else {}
raw_steps = settings.get('steps')
Expand Down Expand Up @@ -325,7 +327,7 @@ def _execute_condition_reply_step(clead, step, now=None):
if yes_branch_step_order and yes_branch_step_order > step.step_order:
steps = _get_campaign_steps(clead.campaign)
yes_step = next((s for s in steps if s.step_order == yes_branch_step_order), None)

if yes_step:
_activate_step(clead, yes_step, now=now)
logger.info(
Expand Down Expand Up @@ -363,12 +365,12 @@ def _execute_condition_reply_step(clead, step, now=None):
)
return

# Condition window expired β€” route to "no" branch or finish.
# Condition window expired -- route to "no" branch or finish.
logger.info(f"Reply window expired for {clead.lead.email}")
if no_branch_step_order and no_branch_step_order > step.step_order:
steps = _get_campaign_steps(clead.campaign)
no_step = next((s for s in steps if s.step_order == no_branch_step_order), None)

if no_step:
_activate_step(clead, no_step, now=now)
logger.info(
Expand Down Expand Up @@ -500,15 +502,13 @@ def _execute_call_step(clead, step, now=None):
sid = initiate_call(phone, call_script or None)
logger.info(f"Call initiated to {clead.lead.email} ({phone}) | sid={sid}")
except RuntimeError:

logger.info(f"CALL step (manual) for {clead.lead.email} ({phone}): {call_script or 'No script'}")
except Exception as err:
logger.error(f"Call failed for {clead.lead.email}: {err}")

_advance_to_next_step(clead, step, now=now)



def rewrite_email_links(html_body, campaign_lead_id, step_id):
"""
Parses the email body, finds all anchor tags, and replaces the href
Expand All @@ -519,34 +519,38 @@ def rewrite_email_links(html_body, campaign_lead_id, step_id):

soup = BeautifulSoup(html_body, 'html.parser')
signer = Signer()

token_payload = f"{campaign_lead_id}:{step_id}"
signed_token = signer.sign(token_payload)


base_url = getattr(django_settings, 'BACKEND_BASE_URL', 'http://127.0.0.1:8000').rstrip('/')
tracking_endpoint = f"{base_url}/api/v1/clicks/track/"

for a_tag in soup.find_all('a', href=True):
original_url = a_tag.get('href', '')

if not original_url or original_url.startswith(('mailto:', 'tel:')) or tracking_endpoint in original_url:
continue

encoded_dest = urllib.parse.quote(original_url, safe='')

tracking_url = f"{tracking_endpoint}?t={signed_token}&dest={encoded_dest}"

scheme = urllib.parse.urlsplit(original_url).scheme.lower()
if scheme and scheme not in ('http', 'https'):
# Never generate a tracking redirect for a non-http(s) destination.
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# Sign campaign_lead_id, step_id, AND the destination together so the
# destination can't be swapped out while reusing a previously-issued,
# otherwise-valid token (open-redirect prevention).
token_payload = f"{campaign_lead_id}:{step_id}:{original_url}"
signed_token = signer.sign(token_payload)

tracking_url = f"{tracking_endpoint}?t={urllib.parse.quote(signed_token, safe='')}"
a_tag['href'] = tracking_url

return str(soup)
# -----------------------------------


@shared_task
def send_email_step(campaign_lead_id, step_id):
"""
Dispatch an email through the selected connected account or fall back to mock logging.
"""

try:
clead = CampaignLead.objects.select_related('lead', 'campaign__connected_account').get(id=campaign_lead_id)
step = SequenceStep.objects.get(id=step_id)
Expand Down Expand Up @@ -583,9 +587,7 @@ def send_email_step(campaign_lead_id, step_id):

subject, body = personalize_email(step.template_subject, step.template_body, clead.lead)


body = rewrite_email_links(body, campaign_lead_id, step_id)
# -------------------------------------------

account = clead.campaign.connected_account
if account:
Expand Down Expand Up @@ -667,10 +669,10 @@ def process_active_leads_once(now=None):
if not clead.current_step:
if clead.status not in {'ENROLLED', 'ACTIVE'}:
break

steps = _get_campaign_steps(clead.campaign)
first_step = steps[0] if steps else None

if not first_step:
break
clead.current_step = first_step
Expand Down Expand Up @@ -803,7 +805,7 @@ def check_imap_bounces():
return "Bounce polling disabled"

accounts = (
ConnectedEmailAccount._default_manager.filter(
ConnectedEmailAccount.objects.filter(
campaigns__enrolled_leads__last_sent_message_id__isnull=False,
campaigns__enrolled_leads__status__in=['ACTIVE', 'ENROLLED', 'FINISHED'],
)
Expand All @@ -820,7 +822,8 @@ def check_imap_bounces():
candidates = find_imap_bounce_candidates(account)
else:
logger.info(
f"Skipping bounce polling for {account.email_address}: provider {account.provider} is not supported yet."
f"Skipping bounce polling for {account.email_address}: "
f"provider {account.provider} is not supported yet."
)
continue
except Exception as exc:
Expand Down Expand Up @@ -861,4 +864,4 @@ def check_imap_bounces():
f"Failed to mark bounce email {message_id} as read for {account.email_address}: {exc}"
)

return f"Processed {scanned_messages} bounce emails and marked {total_bounced} campaign leads as BOUNCED."
return f"Processed {scanned_messages} bounce emails and marked {total_bounced} campaign leads as BOUNCED."
29 changes: 18 additions & 11 deletions backend/campaigns/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -782,34 +782,41 @@ class ClickTrackingView(APIView):

def get(self, request, *args, **kwargs):
signed_token = request.GET.get('t')
dest_url = request.GET.get('dest')

if not signed_token or not dest_url:
if not signed_token:
return HttpResponseBadRequest("Missing tracking parameters.")

signer = Signer()
try:
# Decode and verify the token signature
# The destination is signed together with campaign_lead_id/step_id
# (see tasks.rewrite_email_links), so a tampered or substituted
# destination will fail signature verification here rather than
# being redirected to.
unsigned_payload = signer.unsign(signed_token)
campaign_lead_id, step_id = unsigned_payload.split(':')
campaign_lead_id, step_id, dest_url = unsigned_payload.split(':', 2)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
except (BadSignature, ValueError):
return HttpResponseBadRequest("Invalid or tampered tracking token.")

# Analytics ko update karna
parsed_dest = urllib.parse.urlsplit(dest_url)
if parsed_dest.scheme.lower() not in ('http', 'https'):
return HttpResponseBadRequest("Invalid or tampered tracking token.")

# Update click analytics for the lead.
try:
lead = CampaignLead.objects.get(id=campaign_lead_id)
lead.last_clicked_at = timezone.now()
lead.save(update_fields=['last_clicked_at'])

# Optional: Agar conditionally aage badhana hai sequence ko
# If the lead is currently parked on a CONDITION_CLICK step,
# advance the sequence immediately.
if lead.current_step and lead.current_step.channel_type == 'CONDITION_CLICK':
from .tasks import _execute_condition_click_step
_execute_condition_click_step(lead, lead.current_step, now=timezone.now())

except CampaignLead.DoesNotExist:
pass # Failsafe: Continue to redirect even if the lead was deleted
pass # Failsafe: still redirect even if the lead was deleted.

# Original Destination par redirect karna
decoded_dest = urllib.parse.unquote(dest_url)
return HttpResponseRedirect(decoded_dest)
# ------------------------------------------
# dest_url came from inside the verified signature, so it's safe to
# redirect to as-is (it was never re-encoded before signing).
return HttpResponseRedirect(dest_url)
# ------------------------------------------