import hashlib import hmac import io 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.paginator import Paginator from django.core.validators import validate_email from django.http import FileResponse, 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, Message, MessageTemplate, ProviderEvent, StoredFile from messaging.providers.postcard.pcm import ( PCM_SIZE_CHOICES, PcmApiError, create_custom_design, design_id_from_template, get_design_embed_url, list_designs, ) from messaging.services import ( create_campaign_draft, enqueue_campaign_send, message_is_removable, opted_in_contacts, parse_scheduled_for, record_sms_stop, send_campaign_test_email, ) from messaging.webhooks import ( PROVIDER_EMAIL, PROVIDER_PCM, PROVIDER_SMS, campaign_engagement_stats, classify_smtp2go_payload, parse_webhook_payload, process_pcm_postcard_webhook, process_smtp2go_email_webhook, process_smtp2go_sms_webhook, ) logger = logging.getLogger(__name__) RECIPIENTS_PER_PAGE = 50 _ALLOWED_IMAGE_TYPES = frozenset( {"image/jpeg", "image/png", "image/gif", "image/webp"} ) _MAX_IMAGE_BYTES = 5 * 1024 * 1024 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 _fetch_pcm_designs() -> tuple[list[dict], str]: """Return (normalized design rows, api_error).""" designs: list[dict] = [] api_error = "" 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) 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) return designs, api_error def _postcard_design_choices() -> list[dict]: """Options for campaign compose: PCM designs + saved templates.""" designs, _ = _fetch_pcm_designs() by_id = {d["design_id"]: d for d in designs} choices: list[dict] = [] for tmpl in _postcard_templates(): did = design_id_from_template(tmpl) if did is None: continue did_s = str(did) choices.append( { "value": f"t:{tmpl.pk}", "label": f"{tmpl.name} (design {did_s})", "design_id": did_s, } ) by_id.pop(did_s, None) for did_s, d in by_id.items(): choices.append( { "value": f"d:{did_s}", "label": f"{d['name']} (design {did_s})", "design_id": did_s, "size": d.get("size") or "46", "name": d["name"], } ) return choices def _resolve_postcard_template(raw: str) -> MessageTemplate | None: """Resolve compose select value ``t:`` or ``d:``.""" value = (raw or "").strip() if not value: return None if value.startswith("t:"): return MessageTemplate.objects.filter( pk=value[2:], channel=Channel.POSTCARD ).first() if value.startswith("d:"): design_raw = value[2:].strip() try: design_id = int(design_raw) except ValueError: return None for tmpl in MessageTemplate.objects.filter(channel=Channel.POSTCARD): if design_id_from_template(tmpl) == design_id: return tmpl name = f"PCM design {design_id}" designs, _ = _fetch_pcm_designs() match = next( (d for d in designs if d["design_id"] == str(design_id)), None ) size = (match or {}).get("size") or "46" if match and match.get("name"): name = match["name"] front = { "design_id": design_id, "size": size, "name": name, "provider": "pcm", } return MessageTemplate.objects.create( channel=Channel.POSTCARD, name=name[:120], subject="", body=f"PCM design {design_id}", postcard_front=front, postcard_back={}, ) # Legacy: bare MessageTemplate pk return MessageTemplate.objects.filter( pk=value, channel=Channel.POSTCARD ).first() def _format_postal_address(addr: dict | None) -> str: if not addr: return "" line1 = (addr.get("line1") or "").strip() line2 = (addr.get("line2") or "").strip() city = (addr.get("city") or "").strip() state = (addr.get("state") or "").strip() zip_code = (addr.get("zip") or "").strip() city_line = ", ".join(p for p in (city, state) if p) if zip_code: city_line = f"{city_line} {zip_code}".strip() return ", ".join(p for p in (line1, line2, city_line) if p) def _message_destination(message) -> str: """Channel-specific destination shown on the recipients table.""" contact = message.contact channel = message.channel or (message.campaign.channel if message.campaign_id else "") if channel == Channel.EMAIL: return (contact.email or "").strip() if channel == Channel.SMS: return (contact.phone or "").strip() if channel == Channel.POSTCARD: return _format_postal_address(contact.postal_address) return "" def _events_provider_filter(campaign: Campaign) -> tuple[list[str], str, str]: """Return (provider codes, panel title, empty-state hint) for campaign channel.""" if campaign.channel == Channel.POSTCARD: return ( [PROVIDER_PCM], "Recent PCM Integrations events", "No PCM webhook events yet. PCM must POST to " "/portal/messaging/webhooks/postcard/.", ) if campaign.channel == Channel.SMS: return ( [PROVIDER_SMS], "Recent SMTP2GO events", "No webhook events yet. SMTP2GO must POST SMS events to " "/portal/messaging/webhooks/smtp2go/.", ) return ( [PROVIDER_EMAIL], "Recent SMTP2GO events", "No webhook events yet. SMTP2GO must POST opens/clicks to " "/portal/messaging/webhooks/smtp2go/ " "(see messaging README). SMTP2GO’s own “Clicked” feed does not fill " "this table by itself.", ) def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict: qs = campaign.messages.select_related("contact").order_by( "contact__first_name", "contact__last_name", "created_at" ) paginator = Paginator(qs, RECIPIENTS_PER_PAGE) page_obj = paginator.get_page(page) recipient_messages = list(page_obj.object_list) for msg in recipient_messages: msg.destination = _message_destination(msg) msg.can_remove = message_is_removable(msg) stats = campaign_engagement_stats(campaign) providers, events_title, events_empty = _events_provider_filter(campaign) recent_events = ( ProviderEvent.objects.filter( message__campaign=campaign, provider__in=providers, ) .select_related("message", "message__contact") .order_by("-created_at")[:25] ) return { "recipient_messages": recipient_messages, "page_obj": page_obj, "stats": stats, "recent_events": recent_events, "events_title": events_title, "events_empty": events_empty, } 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 _log_webhook_request(request, *, channel: str) -> None: """Full request dump for Grafana / log aggregation.""" try: headers = {str(k): str(v) for k, v in request.headers.items()} except Exception: # noqa: BLE001 headers = {"_error": "unable to serialize headers"} try: body_text = (request.body or b"").decode("utf-8", errors="replace") except Exception: # noqa: BLE001 body_text = repr(request.body) if len(body_text) > 12000: body_text = body_text[:12000] + "…[truncated]" logger.info( "webhook_received channel=%s path=%s method=%s query=%s", channel, request.path, request.method, request.META.get("QUERY_STRING", ""), ) logger.info("webhook_headers channel=%s headers=%s", channel, headers) logger.info("webhook_body channel=%s body=%s", channel, body_text) def _log_webhook_auth_failed(request, *, channel: str) -> None: logger.warning( "webhook_auth_failed channel=%s path=%s " "missing_or_invalid_authorization_or_token", channel, request.path, ) def _log_webhook_result( *, channel: str, event=None, error: str = "", extra: str = "", ) -> None: if error: logger.error( "webhook_error channel=%s error=%s %s", channel, error, extra, ) return if not event: logger.warning( "webhook_unmatched channel=%s no_provider_event_created %s", channel, extra, ) return message = getattr(event, "message", None) campaign = getattr(message, "campaign", None) if message else None logger.info( "webhook_processed channel=%s event_type=%s event_id=%s " "matched=%s message_id=%s campaign_id=%s campaign_name=%s %s", channel, getattr(event, "event_type", ""), getattr(event, "pk", None), bool(message), getattr(message, "pk", None), getattr(campaign, "pk", None), getattr(campaign, "name", "") or "", extra, ) 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 = _resolve_postcard_template(template_id) 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 postcard design (create one under Postcard design)." ) 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_designs": _postcard_design_choices(), "form_data": form_data, "form_errors": form_errors, "image_upload_url": reverse("messaging:campaign_image_upload"), }, ) @login_required def campaign_detail(request, pk): campaign = get_object_or_404(Campaign, pk=pk) try: page = max(1, int(request.GET.get("page") or 1)) except (TypeError, ValueError): page = 1 ctx = _campaign_report(campaign, page=page) return render( request, "messaging/campaign_detail.html", { "campaign": campaign, "recipient_messages": ctx["recipient_messages"], "page_obj": ctx["page_obj"], "stats": ctx["stats"], "recent_events": ctx["recent_events"], "events_title": ctx["events_title"], "events_empty": ctx["events_empty"], "can_send": campaign.status in { Campaign.Status.DRAFT, Campaign.Status.SCHEDULED, Campaign.Status.SENDING, } and campaign.messages.exclude( status__in={ "sent", "delivered", "opened", "clicked", "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() try: page = max(1, int(request.GET.get("page") or 1)) except (TypeError, ValueError): page = 1 ctx = _campaign_report(campaign, page=page) page_obj = ctx["page_obj"] return JsonResponse( { "status": campaign.status, "status_display": campaign.get_status_display(), "stats": ctx["stats"], "page": page_obj.number, "num_pages": page_obj.paginator.num_pages, "messages": [ { "id": str(m.pk), "contact": str(m.contact), "destination": getattr(m, "destination", "") or "", "status": m.status, "status_display": m.get_status_display(), "provider_message_id": m.provider_message_id or "", "error": (m.error or "")[:120], "can_remove": bool(getattr(m, "can_remove", False)), } for m in ctx["recipient_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_message_remove(request, pk, message_id): """Drop a draft/scheduled/failed recipient from the campaign.""" campaign = get_object_or_404(Campaign, pk=pk) message = get_object_or_404(Message, pk=message_id, campaign=campaign) if not message_is_removable(message): messages.error( request, "Only draft, scheduled, or failed recipients can be removed.", ) return redirect("messaging:campaign_detail", pk=campaign.pk) label = str(message.contact) message.delete() messages.success(request, f"Removed {label} from this campaign.") page = (request.POST.get("page") or request.GET.get("page") or "").strip() if page and page.isdigit() and int(page) > 1: return redirect( f"{reverse('messaging:campaign_detail', kwargs={'pk': campaign.pk})}" f"?page={page}" ) return redirect("messaging:campaign_detail", pk=campaign.pk) @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 @require_POST def campaign_image_upload(request): """Upload an image for the email rich editor; store bytes in the DB.""" upload = request.FILES.get("image") or request.FILES.get("file") if not upload: return JsonResponse({"error": "No image uploaded."}, status=400) content_type = (getattr(upload, "content_type", None) or "").lower() if content_type not in _ALLOWED_IMAGE_TYPES: return JsonResponse( {"error": "Use a JPEG, PNG, GIF, or WebP image."}, status=400 ) if upload.size and upload.size > _MAX_IMAGE_BYTES: return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400) data = upload.read() if len(data) > _MAX_IMAGE_BYTES: return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400) original = (getattr(upload, "name", None) or "image")[:255] stored = StoredFile.objects.create( kind=StoredFile.Kind.CAMPAIGN_IMAGE, filename=original, content_type=content_type, size=len(data), data=data, uploaded_by=request.user if request.user.is_authenticated else None, ) path = reverse("messaging:stored_file", kwargs={"pk": stored.pk}) url = request.build_absolute_uri(path) return JsonResponse({"url": url, "id": str(stored.pk)}) @require_GET def stored_file(request, pk): """Public fetch for email clients / preview (UUID acts as capability token).""" stored = get_object_or_404(StoredFile, pk=pk) response = FileResponse( io.BytesIO(bytes(stored.data)), content_type=stored.content_type or "application/octet-stream", ) if stored.filename: response["Content-Disposition"] = f'inline; filename="{stored.filename}"' response["Cache-Control"] = "public, max-age=86400" return response @login_required def postcard_designer(request): """PCM Integrations designer — list designs + embed iframe.""" designs, api_error = _fetch_pcm_designs() embed_url = "" active_design_id = (request.GET.get("design_id") or "").strip() active_name = "" active_size = "46" 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:///portal/messaging/webhooks/postcard/ Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS """ channel = "postcard" _log_webhook_request(request, channel=channel) if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()): _log_webhook_auth_failed(request, channel=channel) return HttpResponseForbidden("invalid webhook token") payload = parse_webhook_payload(request) if not payload: payload = request.POST.dict() or {} try: event = process_pcm_postcard_webhook(payload) except Exception as exc: # noqa: BLE001 logger.exception("PCM postcard webhook processing failed") _log_webhook_result(channel=channel, error=str(exc)) return JsonResponse({"ok": False, "error": "processing_failed"}, status=200) _log_webhook_result(channel=channel, event=event) return JsonResponse( { "ok": True, "matched": bool(event and event.message_id), "event_id": event.pk if event else None, } ) @csrf_exempt @require_POST def smtp2go_webhook(request): """ Unified SMTP2GO webhook — email + SMS events + inbound STOP replies. One SMTP2GO webhook URL (paid plans cap at 10 webhooks): URL: https:///portal/messaging/webhooks/smtp2go/ Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET Output type: JSON Users: email SMTP user(s) *and* the SMS API key used to send Email events: all delivery/engagement boxes Email headers: X-Monica-Message-Id SMS events: Submitted, Sending, Delivered, Failed, Rejected (Opt-out if shown) Legacy aliases ``/webhooks/email/`` and ``/webhooks/sms/`` hit this same view. Payload shape selects the processor (email vs sms_* vs inbound STOP). """ # Log/auth before parsing so request.body stays readable (HMAC + Grafana dump). _log_webhook_request(request, channel="smtp2go") if not _webhook_authorized( request, secret=settings.SMTP2GO_WEBHOOK_SECRET or "" ): _log_webhook_auth_failed(request, channel="smtp2go") return HttpResponseForbidden("invalid webhook token") payload = parse_webhook_payload(request) if not payload: payload = request.POST.dict() or {} kind = classify_smtp2go_payload(payload) channel = { "sms_inbound": "sms", "sms": "sms", "email": "email", }.get(kind, "smtp2go") logger.info("webhook_classified channel=%s kind=%s", channel, kind) if kind == "sms_inbound": 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)) logger.info( "webhook_processed channel=sms event_type=inbound_stop " "opt_out=%s phone=%s", stopped, phone, ) return JsonResponse({"ok": True, "channel": "sms", "opt_out": stopped}) if kind == "unknown": logger.warning( "webhook_unmatched channel=smtp2go unrecognized_payload keys=%s", sorted(str(k) for k in payload.keys()), ) return JsonResponse( {"ok": False, "error": "unrecognized_payload", "channel": None}, status=200, ) try: if kind == "sms": event = process_smtp2go_sms_webhook(payload) else: event = process_smtp2go_email_webhook(payload) except Exception as exc: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h) logger.exception("SMTP2GO %s webhook processing failed", kind) _log_webhook_result(channel=channel, error=str(exc)) return JsonResponse( {"ok": False, "error": "processing_failed", "channel": channel}, status=200, ) _log_webhook_result(channel=channel, event=event) return JsonResponse( { "ok": True, "channel": channel, "matched": bool(event and event.message_id), "event_id": event.pk if event else None, } ) # Legacy path names — same unified handler (keep SMTP2GO configs working). email_webhook = smtp2go_webhook sms_webhook = smtp2go_webhook