"""Campaign draft/send helpers for email and SMS.""" from __future__ import annotations import re from typing import TYPE_CHECKING from django.urls import reverse from django.utils import timezone from contacts.consent import ( # noqa: F401 — re-export for tests + providers channel_preferences, contact_may_receive, make_unsubscribe_token, one_click_unsubscribe_url, opted_in_contacts, parse_unsubscribe_token, preferences_url, process_unsubscribe_token, record_sms_stop, set_channel_consent, set_channel_preferences, unsubscribe_all, ) from contacts.models import Channel, Contact from core.campaign_utm import ensure_campaign_utm_link from core.scheduling import parse_scheduled_for from email_sms.models import Campaign, Message, MessageTemplate if TYPE_CHECKING: from django.contrib.auth.models import AbstractBaseUser AUDIENCE_CHANNEL = { Campaign.Audience.EMAIL_OPT_IN: Channel.EMAIL, Campaign.Audience.SMS_OPT_IN: Channel.SMS, } # {{first_name}} preferred; {first_name} also accepted (composer hint legacy). _MERGE_TAG_RE = re.compile( r"\{\{\s*(first_name|last_name|email|phone|full_name)\s*\}\}" r"|\{\s*(first_name|last_name|email|phone|full_name)\s*\}", re.IGNORECASE, ) REMOVABLE_MESSAGE_STATUSES = frozenset( { Message.Status.DRAFT, Message.Status.SCHEDULED, Message.Status.FAILED, } ) def render_merge_tags(text: str, contact: Contact | None) -> str: """Replace personalization tags with contact field values.""" if not text: return text or "" first = (getattr(contact, "first_name", None) or "").strip() if contact else "" last = (getattr(contact, "last_name", None) or "").strip() if contact else "" email = (getattr(contact, "email", None) or "").strip() if contact else "" phone = (getattr(contact, "phone", None) or "").strip() if contact else "" full = f"{first} {last}".strip() values = { "first_name": first, "last_name": last, "email": email, "phone": phone, "full_name": full, } def _replace(match: re.Match[str]) -> str: key = (match.group(1) or match.group(2) or "").lower() return values.get(key, "") return _MERGE_TAG_RE.sub(_replace, text) def message_is_removable(message: Message) -> bool: return message.status in REMOVABLE_MESSAGE_STATUSES def channel_for_audience(audience: str) -> str: try: return AUDIENCE_CHANNEL[audience] except KeyError as exc: raise ValueError(f"Unknown audience: {audience}") from exc def create_campaign_draft( *, name: str, audience: str, subject: str = "", body: str = "", scheduled_for=None, created_by: AbstractBaseUser | None = None, template: MessageTemplate | None = None, ) -> Campaign: """Persist a draft campaign and per-recipient Message stubs.""" channel = channel_for_audience(audience) campaign = Campaign.objects.create( name=name, channel=channel, audience=audience, status=Campaign.Status.DRAFT, scheduled_for=scheduled_for, subject_override=subject, body_override=body, created_by=created_by, template=template, ) if channel in (Channel.EMAIL, Channel.SMS): body = ensure_campaign_utm_link( body, name=name, medium=channel, html=(channel == Channel.EMAIL), campaign_id=campaign.pk, ) if body != campaign.body_override: campaign.body_override = body campaign.save(update_fields=["body_override", "updated_at"]) contacts = list(opted_in_contacts(channel)) Message.objects.bulk_create( [ Message( campaign=campaign, contact=contact, channel=channel, status=Message.Status.DRAFT, scheduled_for=scheduled_for, body_snapshot=body, ) for contact in contacts ] ) return campaign def campaign_notify_recipient(campaign: Campaign) -> str: """Email address for the realtor summary (created_by, else CONTACT_EMAIL).""" from django.conf import settings user = campaign.created_by if user is not None: email = (getattr(user, "email", None) or "").strip() if email: return email return (settings.CONTACT_EMAIL or "").strip() def send_campaign_completion_notify(campaign: Campaign) -> bool: """ One-shot summary email when a campaign finishes sending. Returns True if mail was sent (or already sent earlier). """ from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.db.models import Count, Q from django.template.loader import get_template from django.urls import reverse from public.email_branding import email_brand_context campaign.refresh_from_db() if campaign.notify_sent_at: return True if campaign.status != Campaign.Status.COMPLETED: return False to_email = campaign_notify_recipient(campaign) if not to_email: return False # Claim the notify slot atomically so concurrent refresh calls only send once. now = timezone.now() claimed = Campaign.objects.filter( pk=campaign.pk, status=Campaign.Status.COMPLETED, notify_sent_at__isnull=True, ).update(notify_sent_at=now) if not claimed: return True campaign.notify_sent_at = now counts = campaign.messages.aggregate( sent=Count( "id", filter=Q( status__in=[ Message.Status.SENT, Message.Status.DELIVERED, Message.Status.OPENED, Message.Status.CLICKED, ] ), ), delivered=Count( "id", filter=Q( status__in=[ Message.Status.DELIVERED, Message.Status.OPENED, Message.Status.CLICKED, ] ), ), failed=Count( "id", filter=Q( status__in=[ Message.Status.FAILED, Message.Status.BOUNCED, ] ), ), suppressed=Count("id", filter=Q(status=Message.Status.SUPPRESSED)), total=Count("id"), ) report_path = reverse("email_sms:campaign_detail", kwargs={"pk": campaign.pk}) public = (settings.PUBLIC_SITE_URL or "").rstrip("/") report_url = f"{public}{report_path}" if public else report_path subject = f"Campaign sent: {campaign.name}" ctx = email_brand_context( subject=subject, campaign_name=campaign.name, channel_display=campaign.get_channel_display(), total=counts["total"], sent=counts["sent"], delivered=counts["delivered"], failed=counts["failed"], suppressed=counts["suppressed"], report_url=report_url, ) text_content = get_template("emails/campaign_complete.txt").render(ctx) html_content = get_template("emails/campaign_complete.html").render(ctx) email = EmailMultiAlternatives( subject=subject, body=text_content, from_email=settings.DEFAULT_FROM_EMAIL, to=[to_email], ) email.attach_alternative(html_content, "text/html") try: email.send(fail_silently=False) except Exception: # noqa: BLE001 — don't block completion on mail errors import logging logging.getLogger(__name__).exception( "Campaign completion notify failed for %s", campaign.pk ) Campaign.objects.filter(pk=campaign.pk).update(notify_sent_at=None) campaign.notify_sent_at = None return False return True def refresh_campaign_status(campaign: Campaign) -> Campaign: """Set campaign to completed when no messages remain pending.""" campaign.refresh_from_db() pending = campaign.messages.filter( status__in=[ Message.Status.DRAFT, Message.Status.SCHEDULED, Message.Status.QUEUED, ] ).exists() if pending: return campaign if campaign.status == Campaign.Status.SENDING: campaign.status = Campaign.Status.COMPLETED campaign.save(update_fields=["status", "updated_at"]) send_campaign_completion_notify(campaign) return campaign def enqueue_campaign_send(campaign: Campaign) -> int: """ Queue draft/scheduled/failed messages for send. Dev uses ImmediateBackend → each enqueue runs inline via SMTP/console. """ from email_sms.tasks import send_campaign_message sendable = list( campaign.messages.filter( status__in=[ Message.Status.DRAFT, Message.Status.SCHEDULED, Message.Status.FAILED, ] ) ) if not sendable: return 0 campaign.status = Campaign.Status.SENDING campaign.save(update_fields=["status", "updated_at"]) enqueued = 0 for message in sendable: message.status = Message.Status.QUEUED message.save(update_fields=["status", "updated_at"]) try: send_campaign_message.enqueue(message_id=str(message.pk)) except Exception: # noqa: BLE001 — task already persisted FAILED pass enqueued += 1 refresh_campaign_status(campaign) return enqueued def send_campaign_test_email(campaign: Campaign, to_email: str) -> None: """Send one preview copy to ``to_email`` without touching recipient rows.""" from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template.loader import get_template from public.email_branding import ( campaign_body_to_email_html, campaign_body_to_plain_text, email_brand_context, ) if campaign.channel != Channel.EMAIL: raise ValueError("Test send is only available for email campaigns.") subject = campaign.subject_override or ( campaign.template.subject if campaign.template_id else f"Message from {settings.SITE_NAME}" ) body = campaign.body_override or ( campaign.template.body if campaign.template_id else "" ) if not subject.strip(): raise ValueError("Campaign has no subject.") if not body.strip(): raise ValueError("Campaign has no body.") # Preview merge tags using first recipient when available. sample = ( campaign.messages.select_related("contact") .order_by("created_at") .first() ) sample_contact = sample.contact if sample else None subject = render_merge_tags(subject, sample_contact) body = render_merge_tags(body, sample_contact) notice = ( "This is a test send from the portal. " "Recipient list was not notified." ) ctx = email_brand_context( title=f"[TEST] {subject}", content=f"{campaign_body_to_plain_text(body)}\n\n{notice}", content_html=( f"{campaign_body_to_email_html(body)}" f'

{notice}

' ), ) text_content = get_template("emails/marketing_email.txt").render(ctx) html_content = get_template("emails/marketing_email.html").render(ctx) email = EmailMultiAlternatives( subject=f"[TEST] {subject}", body=text_content, from_email=settings.DEFAULT_FROM_EMAIL, to=[to_email], ) email.attach_alternative(html_content, "text/html") email.send(fail_silently=False)