Files
monica_site/site/messaging/views.py
T
westfarn d830f07757
Deploy Beta / unit-tests (push) Successful in 9s
Deploy Beta / docker (push) Successful in 15s
Deploy Beta / deploy-beta (push) Successful in 1m36s
Send branded HTML emails and harden SMTP2GO webhooks.
Fix campaign stuck on sending under async queue, and stop UUID ValidationError when SMTP2GO tests send "Headers Unavailable".
2026-08-09 06:37:20 -05:00

603 lines
20 KiB
Python

import hashlib
import hmac
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from contacts.models import Channel
from messaging.models import Campaign, MessageTemplate, ProviderEvent
from messaging.providers.postcard.pcm import (
PCM_SIZE_CHOICES,
PcmApiError,
create_custom_design,
get_design_embed_url,
list_designs,
)
from messaging.services import (
create_campaign_draft,
enqueue_campaign_send,
opted_in_contacts,
parse_scheduled_for,
record_sms_stop,
send_campaign_test_email,
)
from messaging.webhooks import (
campaign_engagement_stats,
is_inbound_sms_stop,
parse_webhook_payload,
process_pcm_postcard_webhook,
process_smtp2go_email_webhook,
process_smtp2go_sms_webhook,
)
logger = logging.getLogger(__name__)
def _audience_choices() -> list[tuple[str, str]]:
"""Labeled audience options with live opted-in counts."""
rows = [
(Campaign.Audience.EMAIL_OPT_IN, Channel.EMAIL, "email"),
(Campaign.Audience.SMS_OPT_IN, Channel.SMS, "SMS"),
(Campaign.Audience.POSTCARD_OPT_IN, Channel.POSTCARD, "postcard"),
]
choices = []
for value, channel, label in rows:
count = opted_in_contacts(channel).count()
noun = "contact" if count == 1 else "contacts"
choices.append(
(value, f"Mailing list · {label} opt-in ({count} {noun})")
)
return choices
def _postcard_templates():
return MessageTemplate.objects.filter(channel=Channel.POSTCARD).order_by(
"-updated_at"
)[:50]
def _campaign_report(campaign: Campaign) -> dict:
messages_qs = list(campaign.messages.select_related("contact").all()[:200])
stats = campaign_engagement_stats(campaign)
recent_events = (
ProviderEvent.objects.filter(message__campaign=campaign)
.select_related("message", "message__contact")
.order_by("-created_at")[:25]
)
return {
"messages": messages_qs,
"stats": stats,
"recent_events": recent_events,
}
def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None = None) -> bool:
"""Accept Bearer / ?token= matching any configured secret (constant-time)."""
candidates: list[str] = []
if secrets:
candidates.extend(s.strip() for s in secrets if (s or "").strip())
single = (secret or "").strip()
if single and single not in candidates:
candidates.append(single)
if not candidates:
return True
token = (request.GET.get("token") or "").strip()
auth = (request.headers.get("Authorization") or "").strip()
bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
# Common signature-header names PCM / gateways may use (raw secret or HMAC).
sig_headers = (
request.headers.get("X-PCM-Signature")
or request.headers.get("X-Webhook-Signature")
or request.headers.get("X-Signature")
or request.headers.get("X-Hub-Signature-256")
or ""
).strip()
if sig_headers.lower().startswith("sha256="):
sig_headers = sig_headers[7:].strip()
body = request.body or b""
for candidate in candidates:
if token and hmac.compare_digest(token, candidate):
return True
if bearer and hmac.compare_digest(bearer, candidate):
return True
if sig_headers:
if hmac.compare_digest(sig_headers, candidate):
return True
digest = hmac.new(
candidate.encode("utf-8"), body, hashlib.sha256
).hexdigest()
if hmac.compare_digest(sig_headers, digest):
return True
return False
def _pcm_webhook_secrets() -> list[str]:
"""All PCM subscription signature secrets from env."""
raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip()
single = (getattr(settings, "PCM_WEBHOOK_SECRET", None) or "").strip()
out: list[str] = []
if raw_list:
out.extend(p.strip() for p in raw_list.split(",") if p.strip())
if single and single not in out:
out.append(single)
return out
@login_required
@require_http_methods(["GET", "POST"])
def campaign_list(request):
form_errors: list[str] = []
form_data = {
"name": "",
"subject": "",
"body": "",
"audience": Campaign.Audience.EMAIL_OPT_IN,
"scheduled_for": "",
"template_id": "",
}
if request.method == "POST":
name = (request.POST.get("name") or "").strip()
subject = (request.POST.get("subject") or "").strip()
body = (request.POST.get("body") or "").strip()
audience = (request.POST.get("audience") or "").strip()
scheduled_raw = request.POST.get("scheduled_for") or ""
template_id = (request.POST.get("template_id") or "").strip()
form_data.update(
{
"name": name,
"subject": subject,
"body": body,
"audience": audience,
"scheduled_for": scheduled_raw,
"template_id": template_id,
}
)
template = None
if template_id:
template = MessageTemplate.objects.filter(pk=template_id).first()
if not name:
form_errors.append("Campaign name is required.")
if audience not in Campaign.Audience.values:
form_errors.append("Choose a recipient list.")
if audience == Campaign.Audience.POSTCARD_OPT_IN:
if not template or template.channel != Channel.POSTCARD:
form_errors.append(
"Choose a saved postcard template (design it under Postcard first)."
)
if not body:
body = "Postcard mailing"
else:
if not body:
form_errors.append("Body is required.")
if (
audience == Campaign.Audience.EMAIL_OPT_IN
and not subject
):
form_errors.append("Subject is required for email campaigns.")
scheduled_for = None
try:
scheduled_for = parse_scheduled_for(scheduled_raw)
except ValueError as exc:
form_errors.append(str(exc))
if not form_errors:
campaign = create_campaign_draft(
name=name,
audience=audience,
subject=subject,
body=body,
scheduled_for=scheduled_for,
created_by=request.user,
template=template,
)
recipient_count = campaign.messages.count()
messages.success(
request,
f'Draft “{campaign.name}” saved '
f"({recipient_count} recipient"
f"{'' if recipient_count == 1 else 's'}).",
)
return redirect("messaging:campaign_detail", pk=campaign.pk)
campaigns = Campaign.objects.all()[:100]
return render(
request,
"messaging/campaign_list.html",
{
"campaigns": campaigns,
"audience_choices": _audience_choices(),
"postcard_templates": _postcard_templates(),
"form_data": form_data,
"form_errors": form_errors,
},
)
@login_required
def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
ctx = _campaign_report(campaign)
return render(
request,
"messaging/campaign_detail.html",
{
"campaign": campaign,
"messages": ctx["messages"],
"stats": ctx["stats"],
"recent_events": ctx["recent_events"],
"can_send": campaign.status
in {
Campaign.Status.DRAFT,
Campaign.Status.SCHEDULED,
Campaign.Status.SENDING,
}
and campaign.messages.exclude(
status__in={"sent", "delivered", "suppressed"}
).exists(),
},
)
@login_required
@require_GET
def campaign_status_json(request, pk):
"""JSON snapshot for live-updating the campaign report page."""
campaign = get_object_or_404(Campaign, pk=pk)
# Async queue may finish after enqueue; re-evaluate completion on poll.
from messaging.services import refresh_campaign_status
refresh_campaign_status(campaign)
campaign.refresh_from_db()
ctx = _campaign_report(campaign)
return JsonResponse(
{
"status": campaign.status,
"status_display": campaign.get_status_display(),
"stats": ctx["stats"],
"messages": [
{
"id": str(m.pk),
"contact": str(m.contact),
"status": m.status,
"status_display": m.get_status_display(),
"provider_message_id": m.provider_message_id or "",
"error": (m.error or "")[:120],
}
for m in ctx["messages"]
],
"events": [
{
"event_type": e.event_type,
"contact": str(e.message.contact) if e.message_id else "—",
"created_at": e.created_at.isoformat(),
}
for e in ctx["recent_events"]
],
}
)
@login_required
@require_POST
def campaign_send(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
if campaign.status == Campaign.Status.CANCELLED:
messages.error(request, "Cancelled campaigns cannot be sent.")
return redirect("messaging:campaign_detail", pk=campaign.pk)
count = enqueue_campaign_send(campaign)
campaign.refresh_from_db()
if count == 0:
messages.warning(request, "No draft/scheduled/failed messages to send.")
else:
sent = campaign.messages.filter(status="sent").count()
failed = campaign.messages.filter(status="failed").count()
messages.success(
request,
f"Send finished for {count} message(s): {sent} sent, {failed} failed.",
)
return redirect("messaging:campaign_detail", pk=campaign.pk)
@login_required
@require_POST
def campaign_test_send(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
to_email = (request.POST.get("test_email") or "").strip()
if not to_email:
messages.error(request, "Enter an email address for the test send.")
return redirect("messaging:campaign_detail", pk=campaign.pk)
try:
validate_email(to_email)
except ValidationError:
messages.error(request, "That test email address is not valid.")
return redirect("messaging:campaign_detail", pk=campaign.pk)
try:
send_campaign_test_email(campaign, to_email)
except ValueError as exc:
messages.error(request, str(exc))
except Exception as exc: # noqa: BLE001 — surface SMTP misconfig to portal
messages.error(request, f"Test send failed: {exc}")
else:
messages.success(request, f"Test email sent to {to_email}.")
return redirect("messaging:campaign_detail", pk=campaign.pk)
@login_required
def postcard_designer(request):
"""PCM Integrations designer — list designs + embed iframe."""
api_error = ""
designs: list[dict] = []
embed_url = ""
active_design_id = (request.GET.get("design_id") or "").strip()
active_name = ""
active_size = "46"
try:
remote = list_designs(product_type="postcard")
for item in remote:
if not isinstance(item, dict):
continue
did = item.get("designID") or item.get("design_id") or item.get("id")
if did is None:
continue
size_info = item.get("size") or {}
size_key = (
size_info.get("key")
if isinstance(size_info, dict)
else size_info
) or ""
designs.append(
{
"design_id": str(did),
"name": item.get("friendlyName")
or item.get("name")
or f"Design {did}",
"size": str(size_key),
}
)
except PcmApiError as exc:
api_error = str(exc)
# Merge saved local templates that may not appear in the remote page yet.
seen = {d["design_id"] for d in designs}
for tmpl in _postcard_templates():
front = tmpl.postcard_front or {}
did = front.get("design_id")
if did is None:
continue
did_s = str(did)
if did_s in seen:
continue
designs.insert(
0,
{
"design_id": did_s,
"name": tmpl.name,
"size": str(front.get("size") or ""),
},
)
seen.add(did_s)
if active_design_id:
match = next(
(d for d in designs if d["design_id"] == active_design_id), None
)
if match:
active_name = match["name"]
active_size = match.get("size") or "46"
else:
active_name = f"Design {active_design_id}"
try:
embed_url = get_design_embed_url(active_design_id)
except PcmApiError as exc:
api_error = api_error or str(exc)
return render(
request,
"messaging/postcard_designer.html",
{
"api_error": api_error,
"designs": designs,
"embed_url": embed_url,
"active_design_id": active_design_id,
"active_name": active_name,
"active_size": active_size,
"size_choices": PCM_SIZE_CHOICES,
"new_name": "",
"new_size": "46",
"saved_templates": _postcard_templates(),
},
)
@login_required
@require_POST
def postcard_design_create(request):
name = (request.POST.get("name") or "").strip() or "Untitled postcard"
size = (request.POST.get("size") or "46").strip()
allowed = {code for code, _ in PCM_SIZE_CHOICES}
if size not in allowed:
messages.error(request, "Invalid postcard size.")
return redirect("messaging:postcard_designer")
try:
data = create_custom_design(name=name, size=size)
except PcmApiError as exc:
messages.error(request, f"PCM create failed: {exc}")
return redirect("messaging:postcard_designer")
design_id = data.get("designID") or data.get("design_id")
if design_id is None:
messages.error(request, "PCM did not return a design ID.")
return redirect("messaging:postcard_designer")
messages.success(request, f"Design {design_id} created — edit below.")
return redirect(
f"{reverse('messaging:postcard_designer')}?design_id={design_id}"
)
@login_required
@require_POST
def postcard_design_save(request):
design_id = (request.POST.get("design_id") or "").strip()
template_name = (request.POST.get("template_name") or "").strip()
size = (request.POST.get("size") or "").strip()
if not design_id:
messages.error(request, "Missing design id.")
return redirect("messaging:postcard_designer")
if not template_name:
messages.error(request, "Template name is required.")
return redirect(
f"{reverse('messaging:postcard_designer')}?design_id={design_id}"
)
try:
design_id_int = int(design_id)
except ValueError:
messages.error(request, "Invalid design id.")
return redirect("messaging:postcard_designer")
front = {
"design_id": design_id_int,
"size": size,
"name": template_name,
"provider": "pcm",
}
tmpl, created = MessageTemplate.objects.update_or_create(
channel=Channel.POSTCARD,
name=template_name,
defaults={
"subject": "",
"body": f"PCM design {design_id_int}",
"postcard_front": front,
"postcard_back": {},
},
)
verb = "Created" if created else "Updated"
messages.success(
request,
f"{verb} postcard template “{tmpl.name}” (design {design_id_int}).",
)
return redirect(
f"{reverse('messaging:postcard_designer')}?design_id={design_id}"
)
@csrf_exempt
@require_POST
def postcard_webhook(request):
"""
PCM Integrations order / mail-tracking webhook.
Configure in PCM → Webhooks (one subscription per event):
URL: https://<host>/portal/messaging/webhooks/postcard/
Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS
"""
if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()):
return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request)
if not payload:
payload = request.POST.dict() or {}
event = process_pcm_postcard_webhook(payload)
return JsonResponse(
{
"ok": True,
"matched": bool(event and event.message_id),
"event_id": event.pk if event else None,
}
)
@csrf_exempt
@require_POST
def sms_webhook(request):
"""
SMTP2GO SMS webhook — delivery status events + inbound STOP replies.
Configure a *separate* webhook in SMTP2GO → Settings → Webhooks:
URL: https://<host>/portal/messaging/webhooks/sms/
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
Output type: JSON
SMS events: Submitted, Sending, Delivered, Failed, Rejected, Opt-out
(leave Email events unchecked on this webhook)
Inbound gateway POSTs without ``event`` (text=STOP, from=…) still opt out.
"""
if not _webhook_authorized(
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
):
return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request)
if not payload:
payload = request.POST.dict() or {}
# Inbound reply (STOP) — different payload shape than delivery events.
if is_inbound_sms_stop(payload):
phone = (
payload.get("from")
or payload.get("phone")
or payload.get("source_number")
or payload.get("destination_number")
or ""
)
stopped = bool(phone) and record_sms_stop(str(phone))
return JsonResponse({"ok": True, "opt_out": stopped})
event = process_smtp2go_sms_webhook(payload)
return JsonResponse(
{
"ok": True,
"matched": bool(event and event.message_id),
"event_id": event.pk if event else None,
}
)
@csrf_exempt
@require_POST
def email_webhook(request):
"""
SMTP2GO email event webhook (delivered / open / click / bounce / …).
Configure in SMTP2GO → Settings → Webhooks:
URL: https://<host>/portal/messaging/webhooks/email/
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
Output type: JSON
Email events: all delivery/engagement boxes
Email headers: X-Monica-Message-Id
"""
if not _webhook_authorized(
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
):
return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request)
try:
event = process_smtp2go_email_webhook(payload)
except Exception: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h)
logger.exception("SMTP2GO email webhook processing failed")
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
return JsonResponse(
{
"ok": True,
"matched": bool(event and event.message_id),
"event_id": event.pk if event else None,
}
)