Fix campaign stuck on sending under async queue, and stop UUID ValidationError when SMTP2GO tests send "Headers Unavailable".
438 lines
14 KiB
Python
438 lines
14 KiB
Python
"""Consent checks and unsubscribe helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
from django.core import signing
|
|
from django.db.models import QuerySet
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from contacts.models import Channel, ConsentRecord, Contact, Suppression
|
|
from messaging.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,
|
|
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
|
|
}
|
|
|
|
UNSUB_SALT = "monica-site-unsubscribe"
|
|
UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
|
|
|
|
|
|
def contact_may_receive(contact: Contact, channel: str) -> bool:
|
|
if Suppression.objects.filter(
|
|
contact=contact, channel=channel, active=True
|
|
).exists():
|
|
return False
|
|
consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first()
|
|
return bool(consent and consent.opted_in)
|
|
|
|
|
|
def channel_preferences(contact: Contact) -> dict[str, bool]:
|
|
"""Current opt-in flags for every channel (missing record = False)."""
|
|
flags = {c.value: False for c in Channel}
|
|
for record in contact.consents.all():
|
|
flags[record.channel] = record.opted_in
|
|
return flags
|
|
|
|
|
|
def set_channel_consent(
|
|
contact: Contact,
|
|
channel: str,
|
|
*,
|
|
opted_in: bool,
|
|
reason: str = "",
|
|
) -> None:
|
|
"""Write ConsentRecord + Suppression for one channel."""
|
|
if channel not in Channel.values:
|
|
raise ValueError(f"Unknown channel: {channel}")
|
|
ConsentRecord.objects.update_or_create(
|
|
contact=contact,
|
|
channel=channel,
|
|
defaults={"opted_in": opted_in, "reason": reason},
|
|
)
|
|
Suppression.objects.update_or_create(
|
|
contact=contact,
|
|
channel=channel,
|
|
defaults={
|
|
"active": not opted_in,
|
|
"reason": reason if not opted_in else "",
|
|
},
|
|
)
|
|
|
|
|
|
def set_channel_preferences(
|
|
contact: Contact,
|
|
preferences: dict[str, bool],
|
|
*,
|
|
reason: str = "",
|
|
) -> None:
|
|
"""Update consent for each provided channel key."""
|
|
for channel, opted_in in preferences.items():
|
|
if channel not in Channel.values:
|
|
continue
|
|
set_channel_consent(
|
|
contact, channel, opted_in=bool(opted_in), reason=reason
|
|
)
|
|
|
|
|
|
def unsubscribe_all(contact: Contact, *, reason: str = "unsubscribe_all") -> None:
|
|
for channel in Channel:
|
|
set_channel_consent(
|
|
contact, channel.value, opted_in=False, reason=reason
|
|
)
|
|
|
|
|
|
def make_unsubscribe_token(contact_id: str, channel: str = Channel.EMAIL) -> str:
|
|
return signing.dumps({"c": str(contact_id), "ch": channel}, salt=UNSUB_SALT)
|
|
|
|
|
|
def parse_unsubscribe_token(token: str) -> tuple[Contact | None, str]:
|
|
"""Return (contact, channel) or (None, '') on bad/expired token."""
|
|
try:
|
|
data = signing.loads(token, salt=UNSUB_SALT, max_age=UNSUB_MAX_AGE)
|
|
except signing.BadSignature:
|
|
return None, ""
|
|
contact = (
|
|
Contact.objects.filter(pk=data.get("c"))
|
|
.prefetch_related("consents")
|
|
.first()
|
|
)
|
|
if not contact:
|
|
return None, ""
|
|
channel = data.get("ch") or Channel.EMAIL
|
|
if channel not in Channel.values:
|
|
channel = Channel.EMAIL
|
|
return contact, channel
|
|
|
|
|
|
def process_unsubscribe_token(token: str) -> bool:
|
|
"""One-click opt-out for the channel encoded in the token."""
|
|
contact, channel = parse_unsubscribe_token(token)
|
|
if not contact:
|
|
return False
|
|
set_channel_consent(
|
|
contact, channel, opted_in=False, reason="unsubscribe_link"
|
|
)
|
|
return True
|
|
|
|
|
|
def preferences_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
|
|
token = make_unsubscribe_token(contact_id, channel)
|
|
return reverse("public:unsubscribe", kwargs={"token": token})
|
|
|
|
|
|
def one_click_unsubscribe_url(contact_id: str, channel: str = Channel.EMAIL) -> str:
|
|
token = make_unsubscribe_token(contact_id, channel)
|
|
return reverse("public:unsubscribe_one_click", kwargs={"token": token})
|
|
|
|
|
|
def record_sms_stop(phone: str) -> bool:
|
|
digits = "".join(ch for ch in (phone or "") if ch.isdigit())
|
|
if len(digits) < 7:
|
|
return False
|
|
tail = digits[-10:]
|
|
contact = None
|
|
for row in Contact.objects.exclude(phone="").iterator():
|
|
stored = "".join(ch for ch in row.phone if ch.isdigit())
|
|
if stored.endswith(tail) or tail.endswith(stored[-10:]):
|
|
contact = row
|
|
break
|
|
if not contact:
|
|
return False
|
|
set_channel_consent(
|
|
contact, Channel.SMS, opted_in=False, reason="sms_stop"
|
|
)
|
|
return True
|
|
|
|
|
|
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 opted_in_contacts(channel: str) -> QuerySet[Contact]:
|
|
"""Contacts opted in for channel and not actively suppressed."""
|
|
suppressed = Suppression.objects.filter(
|
|
channel=channel, active=True
|
|
).values_list("contact_id", flat=True)
|
|
qs = (
|
|
Contact.objects.filter(
|
|
consents__channel=channel,
|
|
consents__opted_in=True,
|
|
)
|
|
.exclude(pk__in=suppressed)
|
|
.distinct()
|
|
.order_by("first_name", "last_name", "email")
|
|
)
|
|
if channel == Channel.POSTCARD:
|
|
qs = qs.filter(postal_address__has_key="line1").exclude(
|
|
postal_address__line1=""
|
|
)
|
|
return qs
|
|
|
|
|
|
def parse_scheduled_for(raw: str | None):
|
|
"""Parse optional ``datetime-local`` value into an aware datetime."""
|
|
value = (raw or "").strip()
|
|
if not value:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(value)
|
|
except ValueError as exc:
|
|
raise ValueError("Invalid schedule datetime.") from exc
|
|
if timezone.is_naive(parsed):
|
|
return timezone.make_aware(parsed, timezone.get_current_timezone())
|
|
return parsed
|
|
|
|
|
|
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,
|
|
)
|
|
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=Message.Status.SENT)),
|
|
delivered=Count("id", filter=Q(status=Message.Status.DELIVERED)),
|
|
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("messaging: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 messaging.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 email_brand_context, plain_text_to_email_html
|
|
|
|
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 "Message from Monica"
|
|
)
|
|
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.")
|
|
|
|
notice = (
|
|
"This is a test send from the Monica portal. "
|
|
"Recipient list was not notified."
|
|
)
|
|
ctx = email_brand_context(
|
|
title=f"[TEST] {subject}",
|
|
content=f"{body}\n\n{notice}",
|
|
content_html=(
|
|
f"{plain_text_to_email_html(body)}"
|
|
f'<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">{notice}</p>'
|
|
),
|
|
)
|
|
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) |