Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('campaigns', '0009_campaign_cached_counters'),
('campaigns', '0009_campaignlead_bounce_metadata'),
]

operations = [
migrations.AddField(
model_name='campaignlead',
name='last_bounced_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='campaignlead',
name='last_sent_at',
field=models.DateTimeField(blank=True, null=True),
),
]
2 changes: 2 additions & 0 deletions backend/campaigns/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,11 @@ class CampaignLead(TenantModel):
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ENROLLED')
next_execution_time = models.DateTimeField(null=True, blank=True)
last_sent_message_id = models.CharField(max_length=255, null=True, blank=True)
last_sent_at = models.DateTimeField(null=True, blank=True)
last_opened_at = models.DateTimeField(null=True, blank=True)
last_clicked_at = models.DateTimeField(null=True, blank=True)
last_replied_at = models.DateTimeField(null=True, blank=True)
last_bounced_at = models.DateTimeField(null=True, blank=True)
bounce_type = models.CharField(max_length=32, null=True, blank=True)
bounce_code = models.CharField(max_length=64, null=True, blank=True)
bounce_reason = models.TextField(null=True, blank=True)
Expand Down
30 changes: 18 additions & 12 deletions backend/campaigns/tasks.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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
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 urllib.parse
Expand Down Expand Up @@ -501,6 +503,7 @@ def send_email_step(campaign_lead_id, step_id):
# -------------------------------------------

account = clead.campaign.connected_account
sent_at = timezone.now()
if account:
try:
message_id = send_gmail(
Expand All @@ -511,7 +514,8 @@ def send_email_step(campaign_lead_id, step_id):
unsubscribe_url=build_unsubscribe_url(clead.lead),
)
clead.last_sent_message_id = message_id
clead.save(update_fields=['last_sent_message_id'])
clead.last_sent_at = sent_at
clead.save(update_fields=['last_sent_message_id', 'last_sent_at'])
logger.info(f"Gmail SENT to {clead.lead.email} | msg_id={message_id}")
except Exception as gmail_err:
logger.error(f"Gmail API send failed for {clead.lead.email}: {gmail_err}")
Expand All @@ -520,6 +524,8 @@ def send_email_step(campaign_lead_id, step_id):
clead.save(update_fields=['next_execution_time'])
return
else:
clead.last_sent_at = sent_at
clead.save(update_fields=['last_sent_at'])
logger.info(f"Mock SENDING EMAIL to {clead.lead.email} | Subject: {subject}")

_advance_to_next_step(clead, step)
Expand Down Expand Up @@ -692,4 +698,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."
return f"Detected {total_replies} new replies."
37 changes: 37 additions & 0 deletions backend/campaigns/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,42 @@ def test_send_email_step_does_not_advance_when_gmail_send_fails(self):
self.assertIsNone(campaign_lead.last_sent_message_id)
self.assertGreater(campaign_lead.next_execution_time, timezone.now())

@patch('campaigns.tasks._advance_to_next_step')
def test_send_email_step_records_sent_timestamp_for_mock_delivery(self, mocked_advance):
campaign = Campaign.objects.create(
organization=self.organization,
name='Mock send flow',
status='ACTIVE',
)
email_step = SequenceStep.objects.create(
organization=self.organization,
campaign=campaign,
step_order=1,
channel_type='EMAIL',
delay_minutes=0,
template_subject='Hello',
template_body='Hi there',
)
lead = Lead.objects.create(
organization=self.organization,
email='mock-send@acme.test',
)
campaign_lead = CampaignLead.objects.create(
organization=self.organization,
campaign=campaign,
lead=lead,
current_step=email_step,
status='ACTIVE',
next_execution_time=timezone.now() - timedelta(minutes=1),
)

send_email_step(campaign_lead.id, email_step.id)

campaign_lead.refresh_from_db()
self.assertIsNotNone(campaign_lead.last_sent_at)
self.assertIsNone(campaign_lead.last_sent_message_id)
mocked_advance.assert_called_once()

def test_condition_reply_step_stops_sequence_when_reply_detected(self):
campaign = Campaign.objects.create(
organization=self.organization,
Expand Down Expand Up @@ -1257,6 +1293,7 @@ def test_email_webhook_persists_bounce_metadata(self):
self.assertEqual(campaign_lead.bounce_type, 'soft')
self.assertEqual(campaign_lead.bounce_code, 'mailbox_full')
self.assertEqual(campaign_lead.bounce_reason, 'Mailbox full')
self.assertIsNotNone(campaign_lead.last_bounced_at)

def test_dashboard_analytics_isolates_data_by_tenant(self):
org2 = Organization.objects.create(name='Other Corp')
Expand Down
8 changes: 6 additions & 2 deletions backend/campaigns/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,12 @@ def leads(self, request, pk=None):
'email': cl.lead.email,
'status': cl.status,
'current_step': cl.current_step.step_order if cl.current_step else None,
'last_sent_at': cl.last_sent_message_id,
'last_sent_at': cl.last_sent_at.isoformat() if cl.last_sent_at else None,
'last_sent_message_id': cl.last_sent_message_id,
'last_opened_at': cl.last_opened_at.isoformat() if cl.last_opened_at else None,
'last_clicked_at': cl.last_clicked_at.isoformat() if cl.last_clicked_at else None,
'last_replied_at': cl.last_replied_at.isoformat() if cl.last_replied_at else None,
'last_bounced_at': cl.last_bounced_at.isoformat() if cl.last_bounced_at else None,
'next_execution': cl.next_execution_time.isoformat() if cl.next_execution_time else None,
})

Expand Down Expand Up @@ -436,6 +438,7 @@ def post(self, request, *args, **kwargs):
for cl in cleads:
if event_type == 'bounce':
cl.status = 'BOUNCED'
cl.last_bounced_at = now
if bounce_details['bounce_type']:
cl.bounce_type = bounce_details['bounce_type']
if bounce_details['bounce_code']:
Expand All @@ -447,6 +450,7 @@ def post(self, request, *args, **kwargs):
'bounce_type',
'bounce_code',
'bounce_reason',
'last_bounced_at',
])
logger.info(
'Webhook bounce processed for email=%s type=%s code=%s reason=%s',
Expand Down Expand Up @@ -820,4 +824,4 @@ def get(self, request, *args, **kwargs):
# Original Destination par redirect karna
decoded_dest = urllib.parse.unquote(dest_url)
return HttpResponseRedirect(decoded_dest)
# ------------------------------------------
# ------------------------------------------
63 changes: 63 additions & 0 deletions backend/leads/tests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from datetime import timedelta
from rest_framework import status
from rest_framework.test import APITestCase

from campaigns.models import Campaign, CampaignLead
from leads.models import BlockedDomain, Lead, Tag, LeadTag, LeadImportJob
from leads.tasks import import_leads_from_csv
from tenants.models import Organization
Expand Down Expand Up @@ -461,3 +463,64 @@ def test_filter_does_not_leak_other_org_leads(self):
resp = self._get()
emails = {l['email'] for l in resp.data}
self.assertNotIn('spy@example.com', emails)


class LeadTimelineTests(APITestCase):
def setUp(self):
self.org = Organization.objects.create(name='Timeline Org')
self.user = _make_user(self.org, email='timeline@example.com')
self.client.force_authenticate(self.user)
self.lead = _make_lead(
self.org,
'timeline@example.com',
first_name='Taylor',
last_name='Lane',
company='Orbit Labs',
score=88,
)
self.campaign = Campaign.objects.create(
organization=self.org,
name='Outbound 1',
status='ACTIVE',
)

base = self.lead.created_at
self.campaign_lead = CampaignLead.objects.create(
organization=self.org,
campaign=self.campaign,
lead=self.lead,
status='BOUNCED',
last_sent_message_id='msg-123',
last_sent_at=base + timedelta(minutes=5),
last_opened_at=base + timedelta(minutes=10),
last_clicked_at=base + timedelta(minutes=15),
last_replied_at=base + timedelta(minutes=20),
last_bounced_at=base + timedelta(minutes=25),
bounce_type='soft',
bounce_code='mailbox_full',
bounce_reason='Mailbox full',
)

def test_timeline_returns_chronological_event_history(self):
response = self.client.get(f'/api/v1/leads/{self.lead.id}/timeline/')

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['lead']['email'], 'timeline@example.com')

events = response.data['events']
self.assertEqual(
[event['type'] for event in events],
[
'lead_created',
'campaign_enrolled',
'email_sent',
'email_opened',
'link_clicked',
'reply_received',
'email_bounced',
],
)
self.assertFalse(events[2]['estimated'])
self.assertEqual(events[2]['campaign'], 'Outbound 1')
self.assertIn('msg-123', events[2]['description'])
self.assertIn('mailbox_full', events[-1]['description'])
Loading