"""Campaign draft/send helpers for direct mail.""" 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.scheduling import parse_scheduled_for from directmail.models import Campaign, Message, MessageTemplate if TYPE_CHECKING: from django.contrib.auth.models import AbstractBaseUser AUDIENCE_CHANNEL = { Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD, Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD, } # {{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 parse_route_targeting(raw: str | dict | None) -> dict: """Parse compose JSON for a carrier-route campaign.""" from directmail.providers.postcard.pcm import MAX_SELECTED_ROUTES data: dict = {} if isinstance(raw, dict): data = raw elif isinstance(raw, str) and raw.strip(): import json try: parsed = json.loads(raw) except json.JSONDecodeError as exc: raise ValueError("Carrier route selection is invalid JSON.") from exc if not isinstance(parsed, dict): raise ValueError("Carrier route selection must be an object.") data = parsed codes = [ str(code).strip() for code in (data.get("selected_codes") or data.get("routes") or []) if str(code).strip() ] # Allow routes as list of objects from the quote endpoint. if not codes and isinstance(data.get("routes"), list): codes = [ str(row.get("code") or "").strip() for row in data["routes"] if isinstance(row, dict) ] codes = [c for c in codes if c] codes = list(dict.fromkeys(codes)) if not codes: raise ValueError("Select one or two USPS carrier routes.") if len(codes) > MAX_SELECTED_ROUTES: raise ValueError("Pick at most two carrier routes.") try: list_count_id = int(data.get("list_count_id") or 0) record_count = int(data.get("record_count") or 0) except (TypeError, ValueError) as exc: raise ValueError("Route quote is missing list count.") from exc if list_count_id <= 0 or record_count <= 0: raise ValueError("Find routes and select them before saving (need a PCM quote).") estimated = data.get("estimated") if isinstance(data.get("estimated"), dict) else {} route_rows = data.get("route_rows") if isinstance(data.get("route_rows"), list) else [] return { "selected_codes": codes, "list_count_id": list_count_id, "record_count": record_count, "mail_class": str(data.get("mail_class") or "Standard"), "list_type": str(data.get("list_type") or "IRL"), "zip": str(data.get("zip") or ""), "estimated": estimated, "route_rows": route_rows, } def money_total(per_piece: float | None, quantity: int) -> float | None: if per_piece is None: return None return round(float(per_piece) * max(int(quantity or 0), 0), 2) def postcard_quote(*, action: str, payload: dict) -> dict: """Search routes or price a mailing-list / selected-route postcard.""" from directmail.providers.postcard.pcm import ( MAX_SELECTED_ROUTES, design_pricing, extract_crrt_rows, list_count_carrier_route, list_count_radius, list_count_zipcode, mail_class_for_size, pick_unit_price, ) from directmail.providers.postcard.usps_eddm import ( USPS_EDDM_MAP_URL, enrich_crrt_rows, ) design_id = payload.get("design_id") size = str(payload.get("size") or "").strip() try: design_id_int = int(design_id) if design_id not in (None, "") else None except (TypeError, ValueError): design_id_int = None requested_class = str(payload.get("mail_class") or "").strip() mail_class = requested_class or mail_class_for_size(size) if mail_class not in {"FirstClass", "Standard"}: mail_class = mail_class_for_size(size) pricing = design_pricing(design_id_int) def _price(qty: int) -> tuple[float | None, str]: return pick_unit_price(pricing, mail_class=mail_class, quantity=qty) action = (action or "mailing_list").strip() if action == "mailing_list": count = opted_in_contacts(Channel.POSTCARD).count() per_piece, source = _price(count) return { "action": action, "mail_class": mail_class, "count": count, "per_piece": per_piece, "total": money_total(per_piece, count), "price_source": source, } if action == "search": zip_code = str(payload.get("zip") or "").strip() address = str(payload.get("address") or "").strip() city = str(payload.get("city") or "").strip() state = str(payload.get("state") or "").strip() try: miles = float(payload.get("radius") or 0) except (TypeError, ValueError): miles = 0 if address and city and state and zip_code and miles > 0: data = list_count_radius( address=address, city=city, state=state, zip_code=zip_code, miles=miles, ) elif zip_code: data = list_count_zipcode(zip_codes=[zip_code]) else: raise ValueError("Enter a ZIP code (or address + ZIP + radius).") rows = enrich_crrt_rows(extract_crrt_rows(data), zip_hint=zip_code) priced = [] for row in rows: per_piece, source = _price(row["total"]) priced.append( { **row, "per_piece": per_piece, "cost": money_total(per_piece, row["total"]), "price_source": source, } ) return { "action": action, "mail_class": mail_class, "zip": zip_code, "routes": priced, "eddm_map_url": USPS_EDDM_MAP_URL, "discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"), } if action == "select": codes = [ str(c).strip() for c in (payload.get("routes") or []) if str(c).strip() ] codes = list(dict.fromkeys(codes)) if not codes: raise ValueError("Select at least one carrier route.") if len(codes) > MAX_SELECTED_ROUTES: raise ValueError("Pick at most two carrier routes.") data = list_count_carrier_route(carrier_routes=codes) rows = extract_crrt_rows(data) if not rows: try: record_count = int(data.get("recordCount") or data.get("record_count") or 0) except (TypeError, ValueError): record_count = 0 rows = [{"code": code, "text": code, "total": 0} for code in codes] if record_count and len(rows) == 1: rows[0]["total"] = record_count rows = enrich_crrt_rows(rows, zip_hint=codes[0] if codes else "") record_count = int(data.get("recordCount") or data.get("record_count") or 0) if not record_count: record_count = sum(int(r.get("total") or 0) for r in rows) per_piece, source = _price(record_count) priced = [] for row in rows: total = int(row.get("total") or 0) priced.append( { **row, "per_piece": per_piece, "cost": money_total(per_piece, total), "price_source": source, } ) list_count_id = data.get("listCountID") or data.get("list_count_id") return { "action": action, "mail_class": mail_class, "selected_codes": codes, "routes": priced, "route_rows": priced, "list_count_id": list_count_id, "record_count": record_count, "per_piece": per_piece, "total": money_total(per_piece, record_count), "price_source": source, "eddm_map_url": USPS_EDDM_MAP_URL, "estimated": { "per_piece": per_piece, "pieces": record_count, "total": money_total(per_piece, record_count), "currency": "USD", "source": source, }, } raise ValueError(f"Unknown quote action: {action}") def create_campaign_draft( *, name: str, audience: str, subject: str = "", body: str = "", scheduled_for=None, created_by: AbstractBaseUser | None = None, template: MessageTemplate | None = None, route_targeting: dict | None = None, ) -> Campaign: """Persist a draft campaign and per-recipient Message stubs.""" channel = channel_for_audience(audience) targeting = route_targeting if isinstance(route_targeting, dict) else {} 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, route_targeting=targeting, ) if audience == Campaign.Audience.CARRIER_ROUTE: Message.objects.create( campaign=campaign, contact=None, channel=channel, status=Message.Status.DRAFT, scheduled_for=scheduled_for, body_snapshot=body, ) return campaign 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("directmail: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 directmail.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)