PCM DirectMail v3 needs POST /auth/login (apiKey+apiSecret) before design/order calls; accept each subscription's copy-only signature secret via PCM_WEBHOOK_SECRETS.
591 lines
19 KiB
Python
591 lines
19 KiB
Python
import hashlib
|
|
import hmac
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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)
|
|
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)
|
|
event = process_smtp2go_email_webhook(payload)
|
|
return JsonResponse(
|
|
{
|
|
"ok": True,
|
|
"matched": bool(event and event.message_id),
|
|
"event_id": event.pk if event else None,
|
|
}
|
|
)
|