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".
This commit is contained in:
+92
-27
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user