"""SMTP2GO email/SMS event webhooks → Message + ProviderEvent updates.""" 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 from email_sms.models import Message, ProviderEvent from email_sms.services import set_channel_consent logger = logging.getLogger(__name__) PROVIDER_EMAIL = "smtp2go_email" PROVIDER_SMS = "smtp2go_sms" PROVIDER_PCM = "pcm" PROVIDER = PROVIDER_EMAIL # backward-compatible alias # Do not move a message backward to a weaker delivery / engagement state. _STATUS_RANK = { Message.Status.DRAFT: 0, Message.Status.SCHEDULED: 1, Message.Status.QUEUED: 2, Message.Status.SENT: 3, Message.Status.FAILED: 3, Message.Status.DELIVERED: 4, Message.Status.OPENED: 5, Message.Status.CLICKED: 6, Message.Status.BOUNCED: 7, Message.Status.SUPPRESSED: 7, } _ENGAGED_OR_DELIVERED = frozenset( { Message.Status.DELIVERED, Message.Status.OPENED, Message.Status.CLICKED, } ) _SENT_OR_BETTER = frozenset( { Message.Status.SENT, Message.Status.DELIVERED, Message.Status.OPENED, Message.Status.CLICKED, } ) _MONICA_HEADER_KEYS = ( "X-Monica-Message-Id", "x-monica-message-id", "X_Monica_Message_Id", "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", } # API sms_events use short names (delivered); webhook body often uses sms_delivered. # UI test labels / Opt-Out may arrive without the sms_ prefix. _SMS_EVENT_ALIASES = { "submitted": "sms_submitted", "sending": "sms_sending", "delivered": "sms_delivered", "failed": "sms_failed", "rejected": "sms_rejected", "opt_out": "sms_opt_out", "optout": "sms_opt_out", "sms_optout": "sms_opt_out", } _SMS_OPT_OUT_EVENTS = frozenset({"sms_opt_out"}) 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 _normalize_sms_event(event: str) -> str: """Map UI / short API names to docs canonical sms_* event strings.""" event = (event or "").strip().lower().replace("-", "_").replace(" ", "_") return _SMS_EVENT_ALIASES.get(event, event) def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]: """Accept JSON or form-encoded SMTP2GO webhook bodies.""" content_type = (request.content_type or "").lower() if "application/json" in content_type: try: data = json.loads(request.body.decode() or "{}") except (json.JSONDecodeError, UnicodeDecodeError): return {} return data if isinstance(data, dict) else {} # Form-encoded (SMTP2GO default) return {key: request.POST.get(key) for key in request.POST.keys()} 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 = _as_str(payload.get(key)) if value: return value headers = payload.get("headers") or payload.get("email_headers") or {} if isinstance(headers, dict): for key in _MONICA_HEADER_KEYS: value = _as_str(headers.get(key)) if value: return value # Case-insensitive scan lower_map = {str(k).lower(): v for k, v in headers.items()} for key in _MONICA_HEADER_KEYS: value = _as_str(lower_map.get(key.lower())) if value: 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_by_pk(monica_id) if message: return message email_id = _as_str(payload.get("email_id") or payload.get("email-id")) if email_id: message = ( Message.objects.select_related("contact", "campaign") .filter(provider_message_id=email_id) .first() ) if message: return message 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 = _as_str(recipients[0]).lower() if not rcpt: return None contact = Contact.objects.filter(email__iexact=rcpt).first() if not contact: return None return ( Message.objects.select_related("contact", "campaign") .filter( contact=contact, channel=Channel.EMAIL, status__in=[ Message.Status.QUEUED, Message.Status.SENT, Message.Status.DELIVERED, Message.Status.OPENED, Message.Status.CLICKED, Message.Status.FAILED, Message.Status.BOUNCED, ], ) .order_by("-sent_at", "-updated_at") .first() ) def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "") -> None: current_rank = _STATUS_RANK.get(message.status, 0) new_rank = _STATUS_RANK.get(new_status, 0) # Always allow bounce/suppress to overwrite delivered; allow delivered over sent. if new_rank < current_rank and new_status not in { Message.Status.BOUNCED, Message.Status.SUPPRESSED, Message.Status.FAILED, }: return if message.status in {Message.Status.BOUNCED, Message.Status.SUPPRESSED} and new_status in { Message.Status.SENT, Message.Status.DELIVERED, Message.Status.OPENED, Message.Status.CLICKED, }: return fields = ["status", "updated_at"] message.status = new_status if error: message.error = error[:2000] fields.append("error") elif new_status in _ENGAGED_OR_DELIVERED: message.error = "" fields.append("error") message.save(update_fields=fields) def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) -> None: 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 = _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 message.save( update_fields=["provider_message_id", "provider", "updated_at"] ) if event == "processed": if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}: _maybe_upgrade_status(message, Message.Status.SENT) return if event == "delivered": _maybe_upgrade_status(message, Message.Status.DELIVERED) return if event == "bounce": status = Message.Status.BOUNCED _maybe_upgrade_status( message, status, error=err or f"{bounce_kind or 'unknown'} bounce", ) if bounce_kind == "hard": set_channel_consent( message.contact, Channel.EMAIL, opted_in=False, reason="smtp2go_hard_bounce", ) return if event == "reject": _maybe_upgrade_status( message, Message.Status.FAILED, error=err or "rejected by provider" ) return if event == "spam": _maybe_upgrade_status( message, Message.Status.SUPPRESSED, error=err or "spam complaint" ) set_channel_consent( message.contact, Channel.EMAIL, opted_in=False, reason="smtp2go_spam", ) return if event == "unsubscribe": _maybe_upgrade_status( message, Message.Status.SUPPRESSED, error="provider unsubscribe" ) set_channel_consent( message.contact, Channel.EMAIL, opted_in=False, reason="smtp2go_unsubscribe", ) return if event == "open": # Open implies delivery; do not overwrite a stronger click status. if message.status != Message.Status.CLICKED: _maybe_upgrade_status(message, Message.Status.OPENED) return if event == "click": _maybe_upgrade_status(message, Message.Status.CLICKED) return # resubscribe — event row only (status unchanged) def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | None: """ Persist ProviderEvent and update Message delivery status when possible. Returns the stored event (even if message could not be matched). """ event = _normalize_email_event(_as_str(payload.get("event"))) if not event: logger.warning("SMTP2GO webhook missing event: %s", payload) return None message = find_message_for_email_event(payload) if message: _apply_email_event(message, event, payload) message.refresh_from_db() else: logger.info( "SMTP2GO webhook unmatched event=%s rcpt=%s email_id=%s", event, payload.get("rcpt"), payload.get("email_id"), ) return ProviderEvent.objects.create( message=message, provider=PROVIDER_EMAIL, event_type=event[:64], payload=_json_safe(payload) if isinstance(payload, dict) else {}, ) def normalize_phone(value: str) -> str: return "".join(ch for ch in (value or "") if ch.isdigit()) def _sms_provider_message_id(payload: dict[str, Any]) -> str: """SMS unique id from docs (`message_id`). Never use webhook `id`.""" return _as_str(payload.get("message_id") or payload.get("sms_id")) def find_message_for_sms_event(payload: dict[str, Any]) -> Message | None: provider_id = _sms_provider_message_id(payload) if provider_id: message = ( Message.objects.select_related("contact", "campaign") .filter(channel=Channel.SMS, provider_message_id=provider_id) .first() ) if message: return message # Outbound delivery events use destination_number (recipient). # Do not use source_number / from — those are the pool or inbound reply. raw_phone = ( payload.get("destination_number") or payload.get("to") or payload.get("phone") or "" ) digits = normalize_phone(_as_str(raw_phone)) if len(digits) < 7: return None # Match last 10 digits so +1 / formatting differences still hit. tail = digits[-10:] contacts = Contact.objects.exclude(phone="").only("id", "phone") contact = None for row in contacts.iterator(): if normalize_phone(row.phone).endswith(tail): contact = row break if not contact: return None return ( Message.objects.select_related("contact", "campaign") .filter( contact=contact, channel=Channel.SMS, status__in=[ Message.Status.QUEUED, Message.Status.SENT, Message.Status.DELIVERED, Message.Status.FAILED, Message.Status.SUPPRESSED, ], ) .order_by("-sent_at", "-updated_at") .first() ) def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> None: event = _normalize_sms_event(event) err = _as_str( payload.get("message") or payload.get("status_code") or payload.get("context") ) provider_id = _sms_provider_message_id(payload) if provider_id and message.provider_message_id != provider_id: message.provider_message_id = provider_id message.provider = PROVIDER_SMS message.save( update_fields=["provider_message_id", "provider", "updated_at"] ) if event in {"sms_sending", "sms_submitted"}: if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}: _maybe_upgrade_status(message, Message.Status.SENT) return if event == "sms_delivered": _maybe_upgrade_status(message, Message.Status.DELIVERED) return if event in {"sms_failed", "sms_rejected"}: _maybe_upgrade_status( message, Message.Status.FAILED, error=err or event, ) return if event in _SMS_OPT_OUT_EVENTS: _maybe_upgrade_status( message, Message.Status.SUPPRESSED, error="sms opt-out" ) set_channel_consent( message.contact, Channel.SMS, opted_in=False, reason="smtp2go_sms_opt_out", ) return def process_smtp2go_sms_webhook(payload: dict[str, Any]) -> ProviderEvent | None: """Persist SMS delivery/opt-out ProviderEvent and update Message when matched.""" event = _normalize_sms_event(_as_str(payload.get("event"))) if not event: logger.warning("SMTP2GO SMS webhook missing event: %s", payload) return None message = find_message_for_sms_event(payload) if message: _apply_sms_event(message, event, payload) message.refresh_from_db() else: # Opt-out with no matched campaign message still suppresses by phone. if event in _SMS_OPT_OUT_EVENTS: phone = _as_str( payload.get("destination_number") or payload.get("from") or payload.get("source_number") ) if phone: from email_sms.services import record_sms_stop record_sms_stop(phone) logger.info( "SMTP2GO SMS webhook unmatched event=%s phone=%s message_id=%s", event, payload.get("destination_number"), payload.get("message_id"), ) return ProviderEvent.objects.create( message=message, provider=PROVIDER_SMS, event_type=event[:64], payload=_json_safe(payload) if isinstance(payload, dict) else {}, ) def is_inbound_sms_stop(payload: dict[str, Any]) -> bool: """True for gateway-style inbound reply payloads (STOP / UNSUBSCRIBE). SMTP2GO auto-handles STOP/UNSUB/UNSUBSCRIBE replies; this catches the inbound gateway POST shape (no ``event`` field) when configured to forward replies. Prefer this over relying on a webhook Opt-Out event — the API ``sms_events`` list does not include opt-out. """ if payload.get("event"): return False text = _as_str( payload.get("text") or payload.get("message") or payload.get("message_content") ).upper() return text in {"STOP", "UNSUBSCRIBE", "UNSUB", "CANCEL", "END", "QUIT"} def classify_smtp2go_payload(payload: dict[str, Any]) -> str: """ Decide email vs SMS vs inbound STOP for a unified SMTP2GO webhook URL. Returns one of: ``sms_inbound``, ``sms``, ``email``, ``unknown``. """ if not isinstance(payload, dict) or not payload: return "unknown" if is_inbound_sms_stop(payload): return "sms_inbound" raw = _as_str(payload.get("event")).lower().replace("-", "_").replace(" ", "_") if not raw: return "unknown" # Explicit SMS forms (docs sms_* + API/UI short names that are SMS-only). if raw.startswith("sms_") or raw in { "submitted", "sending", "opt_out", "optout", }: return "sms" email_event = _normalize_email_event(raw) if email_event in { "processed", "open", "click", "bounce", "spam", "unsubscribe", "resubscribe", }: return "email" has_dest = bool(_as_str(payload.get("destination_number"))) has_rcpt = bool(_as_str(payload.get("rcpt"))) has_email_id = bool( _as_str(payload.get("email_id") or payload.get("email-id")) ) has_monica = bool(extract_monica_message_id(payload)) has_from_address = bool(_as_str(payload.get("from_address"))) has_sms_id = bool(_as_str(payload.get("message_id") or payload.get("sms_id"))) has_sms_body = bool( _as_str(payload.get("message_content") or payload.get("source_number")) ) email_leaning = has_rcpt or has_email_id or has_monica or has_from_address sms_leaning = has_dest or has_sms_body or ( has_sms_id and not email_leaning ) # Ambiguous short names shared by email + SMS API lists. if raw in {"delivered", "failed", "rejected", "reject"}: if sms_leaning and not email_leaning: return "sms" if email_leaning: return "email" # Bare reject without fields → email docs name; rejected alone → sms lean default if raw == "reject": return "email" if raw == "rejected": return "sms" return "email" if email_leaning: return "email" if sms_leaning: return "sms" # Default: email (historical primary SMTP2GO traffic). return "email" def _pcm_event_type(payload: dict[str, Any]) -> str: for key in ("event", "eventType", "event_type", "type", "status"): value = payload.get(key) if value: return str(value).strip() return "unknown" def find_message_for_pcm_event(payload: dict[str, Any]) -> Message | None: """Correlate PCM webhook to Message via extRefNbr or orderID.""" ext = ( payload.get("extRefNbr") or payload.get("ext_ref_nbr") or payload.get("externalReference") or "" ) if not ext and isinstance(payload.get("recipient"), dict): ext = payload["recipient"].get("extRefNbr") or "" ext = str(ext).strip() if ext: message = _message_by_pk(ext) if message: return message order_id = ( payload.get("orderID") or payload.get("orderId") or payload.get("order_id") or "" ) order_id = str(order_id).strip() if order_id: message = ( Message.objects.select_related("contact", "campaign") .filter(provider_message_id=order_id, channel=Channel.POSTCARD) .first() ) if message: return message return None def _apply_pcm_status(message: Message, status: str, payload: dict[str, Any]) -> None: status_norm = (status or "").strip().lower() err = ( payload.get("message") or payload.get("error") or payload.get("reason") or "" ) err = str(err).strip() order_id = ( payload.get("orderID") or payload.get("orderId") or payload.get("order_id") or "" ) if order_id and message.provider_message_id != str(order_id): message.provider_message_id = str(order_id) message.provider = PROVIDER_PCM message.save( update_fields=["provider_message_id", "provider", "updated_at"] ) if status_norm in {"delivered"}: _maybe_upgrade_status(message, Message.Status.DELIVERED) return if status_norm in {"undeliverable", "returned"}: _maybe_upgrade_status( message, Message.Status.BOUNCED, error=err or "undeliverable", ) return if status_norm in {"canceled", "cancelled"}: _maybe_upgrade_status( message, Message.Status.FAILED, error=err or "canceled" ) return if status_norm in {"pending", "processing", "processed", "mailed", "intransit", "in_transit"}: if message.status in { Message.Status.QUEUED, Message.Status.DRAFT, Message.Status.SCHEDULED, }: _maybe_upgrade_status(message, Message.Status.SENT) return def process_pcm_postcard_webhook(payload: dict[str, Any]) -> ProviderEvent | None: """Record a PCM Integrations postcard event and advance Message status.""" if not payload: return None # Nested data wrappers some webhook UIs use. if "data" in payload and isinstance(payload["data"], dict): inner = dict(payload["data"]) for key in ("event", "eventType", "type"): if key in payload and key not in inner: inner[key] = payload[key] payload = inner event_type = _pcm_event_type(payload) message = find_message_for_pcm_event(payload) if message: status_for_apply = ( payload.get("status") or payload.get("orderStatus") or event_type ) _apply_pcm_status(message, str(status_for_apply), payload) return ProviderEvent.objects.create( message=message, provider=PROVIDER_PCM, event_type=event_type[:64], payload=payload, ) def _engagement_chart_bars(metrics: list[tuple[str, int]]) -> list[dict]: """Build bar heights (percent) for the campaign engagement chart.""" peak = max((value for _, value in metrics), default=0) bars: list[dict] = [] for label, value in metrics: if peak <= 0: pct = 12 if value == 0 else 100 else: pct = max(12, int(round((value / peak) * 100))) if value else 8 bars.append({"label": label, "value": value, "pct": pct}) return bars def campaign_engagement_stats(campaign) -> dict: """Aggregate delivery + open/click counts for the campaign report.""" messages_qs = campaign.messages.all() statuses = list(messages_qs.values_list("status", flat=True)) message_ids = list(messages_qs.values_list("pk", flat=True)) events = ProviderEvent.objects.filter(message_id__in=message_ids) open_message_ids = set( events.filter(event_type__iexact="open").values_list("message_id", flat=True) ) click_message_ids = set( events.filter(event_type__iexact="click").values_list("message_id", flat=True) ) # Status-based engagement also counts (webhook may set opened/clicked). status_opened = sum(1 for s in statuses if s in {"opened", "clicked"}) status_clicked = sum(1 for s in statuses if s == "clicked") opens = max(len(open_message_ids), status_opened) clicks = max(len(click_message_ids), status_clicked) sent = sum(1 for s in statuses if s in _SENT_OR_BETTER) delivered = sum(1 for s in statuses if s in _ENGAGED_OR_DELIVERED) failed = sum(1 for s in statuses if s in {"failed", "bounced"}) suppressed = sum(1 for s in statuses if s == "suppressed") if campaign.channel == Channel.EMAIL: chart_metrics = [ ("Sent", sent), ("Delivered", delivered), ("Opens", opens), ("Clicks", clicks), ("Failed", failed), ] else: chart_metrics = [ ("Sent", sent), ("Delivered", delivered), ("Failed", failed), ("Suppressed", suppressed), ] return { "total": len(statuses), "sent": sent, "delivered": delivered, "failed": failed, "bounced": sum(1 for s in statuses if s == "bounced"), "suppressed": suppressed, "opens": opens, "clicks": clicks, "open_events": events.filter(event_type__iexact="open").count(), "click_events": events.filter(event_type__iexact="click").count(), "chart_bars": _engagement_chart_bars(chart_metrics), }