Send branded HTML emails and harden SMTP2GO webhooks.
Deploy Beta / unit-tests (push) Successful in 9s
Deploy Beta / docker (push) Successful in 15s
Deploy Beta / deploy-beta (push) Successful in 1m36s

Fix campaign stuck on sending under async queue, and stop UUID ValidationError when SMTP2GO tests send "Headers Unavailable".
This commit is contained in:
2026-08-09 06:37:20 -05:00
parent 617bda3e8b
commit d830f07757
16 changed files with 558 additions and 63 deletions
+92 -27
View File
@@ -4,8 +4,10 @@ from __future__ import annotations
import json
import logging
import uuid
from typing import Any
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from contacts.models import Channel, Contact
@@ -38,6 +40,45 @@ _MONICA_HEADER_KEYS = (
"monica-message-id",
)
# SMTP2GO UI labels → canonical event strings from their docs.
_EMAIL_EVENT_ALIASES = {
"bounced": "bounce",
"rejected": "reject",
"opened": "open",
"clicked": "click",
"unsubscribed": "unsubscribe",
"resubscribed": "resubscribe",
}
def _as_str(value: Any) -> str:
"""Coerce webhook field values to a stripped string (lists / None safe)."""
if value is None:
return ""
if isinstance(value, (list, tuple)):
if not value:
return ""
value = value[0]
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
return str(value).strip()
def _json_safe(value: Any) -> Any:
"""Ensure ProviderEvent.payload can be stored as JSON."""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(k): _json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(v) for v in value]
return str(value)
def _normalize_email_event(event: str) -> str:
event = (event or "").strip().lower()
return _EMAIL_EVENT_ALIASES.get(event, event)
def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]:
"""Accept JSON or form-encoded SMTP2GO webhook bodies."""
@@ -45,7 +86,7 @@ def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]:
if "application/json" in content_type:
try:
data = json.loads(request.body.decode() or "{}")
except json.JSONDecodeError:
except (json.JSONDecodeError, UnicodeDecodeError):
return {}
return data if isinstance(data, dict) else {}
# Form-encoded (SMTP2GO default)
@@ -55,37 +96,65 @@ def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]:
def extract_monica_message_id(payload: dict[str, Any]) -> str:
"""Pull our correlation id from flat keys or a nested headers object."""
for key in _MONICA_HEADER_KEYS:
value = payload.get(key)
value = _as_str(payload.get(key))
if value:
return str(value).strip()
return value
headers = payload.get("headers") or payload.get("email_headers") or {}
if isinstance(headers, dict):
for key in _MONICA_HEADER_KEYS:
value = headers.get(key)
value = _as_str(headers.get(key))
if value:
return str(value).strip()
return value
# Case-insensitive scan
lower_map = {str(k).lower(): v for k, v in headers.items()}
for key in _MONICA_HEADER_KEYS:
value = lower_map.get(key.lower())
value = _as_str(lower_map.get(key.lower()))
if value:
return str(value).strip()
return value
elif isinstance(headers, list):
# Some ESP shapes send [["X-Monica-Message-Id", "..."], ...]
for item in headers:
if isinstance(item, (list, tuple)) and len(item) >= 2:
if _as_str(item[0]).lower() in {
k.lower() for k in _MONICA_HEADER_KEYS
}:
value = _as_str(item[1])
if value:
return value
elif isinstance(item, str) and ":" in item:
name, _, rest = item.partition(":")
if name.strip().lower() in {k.lower() for k in _MONICA_HEADER_KEYS}:
value = rest.strip()
if value:
return value
return ""
def _message_by_pk(pk: str) -> Message | None:
"""Lookup Message by UUID pk without raising on malformed ids."""
try:
uuid.UUID(str(pk))
except (ValueError, AttributeError, TypeError):
return None
try:
return (
Message.objects.select_related("contact", "campaign")
.filter(pk=pk)
.first()
)
except (ValidationError, ValueError):
return None
def find_message_for_email_event(payload: dict[str, Any]) -> Message | None:
monica_id = extract_monica_message_id(payload)
if monica_id:
message = (
Message.objects.select_related("contact", "campaign")
.filter(pk=monica_id)
.first()
)
message = _message_by_pk(monica_id)
if message:
return message
email_id = (payload.get("email_id") or payload.get("email-id") or "").strip()
email_id = _as_str(payload.get("email_id") or payload.get("email-id"))
if email_id:
message = (
Message.objects.select_related("contact", "campaign")
@@ -95,13 +164,13 @@ def find_message_for_email_event(payload: dict[str, Any]) -> Message | None:
if message:
return message
rcpt = (payload.get("rcpt") or "").strip().lower()
rcpt = _as_str(payload.get("rcpt")).lower()
if not rcpt:
recipients = payload.get("recipients")
if isinstance(recipients, str) and recipients.strip():
rcpt = recipients.split(",")[0].strip().lower()
elif isinstance(recipients, list) and recipients:
rcpt = str(recipients[0]).strip().lower()
rcpt = _as_str(recipients[0]).lower()
if not rcpt:
return None
@@ -157,11 +226,11 @@ def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "")
def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) -> None:
event = (event or "").strip().lower()
bounce_kind = (payload.get("bounce") or "").strip().lower()
err = (payload.get("message") or payload.get("context") or "").strip()
event = _normalize_email_event(event)
bounce_kind = _as_str(payload.get("bounce")).lower()
err = _as_str(payload.get("message") or payload.get("context"))
email_id = (payload.get("email_id") or payload.get("email-id") or "").strip()
email_id = _as_str(payload.get("email_id") or payload.get("email-id"))
if email_id and message.provider_message_id != email_id:
message.provider_message_id = email_id
message.provider = PROVIDER_EMAIL
@@ -233,7 +302,7 @@ def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | No
Returns the stored event (even if message could not be matched).
"""
event = (payload.get("event") or "").strip().lower()
event = _normalize_email_event(_as_str(payload.get("event")))
if not event:
logger.warning("SMTP2GO webhook missing event: %s", payload)
return None
@@ -253,8 +322,8 @@ def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | No
return ProviderEvent.objects.create(
message=message,
provider=PROVIDER_EMAIL,
event_type=event,
payload=payload,
event_type=event[:64],
payload=_json_safe(payload) if isinstance(payload, dict) else {},
)
@@ -442,11 +511,7 @@ def find_message_for_pcm_event(payload: dict[str, Any]) -> Message | None:
ext = payload["recipient"].get("extRefNbr") or ""
ext = str(ext).strip()
if ext:
message = (
Message.objects.select_related("contact", "campaign")
.filter(pk=ext)
.first()
)
message = _message_by_pk(ext)
if message:
return message