Unify SMTP2GO email and SMS into one webhook URL.
Classify payloads on /webhooks/smtp2go/ so one SMTP2GO webhook covers both channels under the 10-webhook limit; keep /email/ and /sms/ as aliases and harden SMS event matching.
This commit is contained in:
+128
-33
@@ -68,6 +68,21 @@ _EMAIL_EVENT_ALIASES = {
|
||||
"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)."""
|
||||
@@ -98,6 +113,12 @@ def _normalize_email_event(event: str) -> str:
|
||||
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()
|
||||
@@ -362,14 +383,13 @@ 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 = (
|
||||
payload.get("message_id")
|
||||
or payload.get("sms_id")
|
||||
or payload.get("id")
|
||||
or ""
|
||||
)
|
||||
provider_id = str(provider_id).strip()
|
||||
provider_id = _sms_provider_message_id(payload)
|
||||
if provider_id:
|
||||
message = (
|
||||
Message.objects.select_related("contact", "campaign")
|
||||
@@ -379,14 +399,15 @@ def find_message_for_sms_event(payload: dict[str, Any]) -> Message | None:
|
||||
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 payload.get("from")
|
||||
or ""
|
||||
)
|
||||
digits = normalize_phone(str(raw_phone))
|
||||
digits = normalize_phone(_as_str(raw_phone))
|
||||
if len(digits) < 7:
|
||||
return None
|
||||
|
||||
@@ -411,6 +432,7 @@ def find_message_for_sms_event(payload: dict[str, Any]) -> Message | None:
|
||||
Message.Status.SENT,
|
||||
Message.Status.DELIVERED,
|
||||
Message.Status.FAILED,
|
||||
Message.Status.SUPPRESSED,
|
||||
],
|
||||
)
|
||||
.order_by("-sent_at", "-updated_at")
|
||||
@@ -419,19 +441,14 @@ def find_message_for_sms_event(payload: dict[str, Any]) -> Message | None:
|
||||
|
||||
|
||||
def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> None:
|
||||
event = (event or "").strip().lower().replace("-", "_")
|
||||
err = (
|
||||
event = _normalize_sms_event(event)
|
||||
err = _as_str(
|
||||
payload.get("message")
|
||||
or payload.get("status_code")
|
||||
or payload.get("context")
|
||||
or ""
|
||||
)
|
||||
err = str(err).strip()
|
||||
|
||||
provider_id = (
|
||||
payload.get("message_id") or payload.get("sms_id") or ""
|
||||
)
|
||||
provider_id = str(provider_id).strip()
|
||||
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
|
||||
@@ -439,16 +456,16 @@ def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> N
|
||||
update_fields=["provider_message_id", "provider", "updated_at"]
|
||||
)
|
||||
|
||||
if event in {"sms_sending", "sending", "sms_submitted", "submitted"}:
|
||||
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 in {"sms_delivered", "delivered"}:
|
||||
if event == "sms_delivered":
|
||||
_maybe_upgrade_status(message, Message.Status.DELIVERED)
|
||||
return
|
||||
|
||||
if event in {"sms_failed", "failed", "sms_rejected", "rejected"}:
|
||||
if event in {"sms_failed", "sms_rejected"}:
|
||||
_maybe_upgrade_status(
|
||||
message,
|
||||
Message.Status.FAILED,
|
||||
@@ -456,7 +473,7 @@ def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> N
|
||||
)
|
||||
return
|
||||
|
||||
if event in {"sms_opt_out", "opt_out", "optout"}:
|
||||
if event in _SMS_OPT_OUT_EVENTS:
|
||||
_maybe_upgrade_status(
|
||||
message, Message.Status.SUPPRESSED, error="sms opt-out"
|
||||
)
|
||||
@@ -471,7 +488,7 @@ def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> N
|
||||
|
||||
def process_smtp2go_sms_webhook(payload: dict[str, Any]) -> ProviderEvent | None:
|
||||
"""Persist SMS delivery/opt-out ProviderEvent and update Message when matched."""
|
||||
event = (payload.get("event") or "").strip().lower()
|
||||
event = _normalize_sms_event(_as_str(payload.get("event")))
|
||||
if not event:
|
||||
logger.warning("SMTP2GO SMS webhook missing event: %s", payload)
|
||||
return None
|
||||
@@ -482,17 +499,16 @@ def process_smtp2go_sms_webhook(payload: dict[str, Any]) -> ProviderEvent | None
|
||||
message.refresh_from_db()
|
||||
else:
|
||||
# Opt-out with no matched campaign message still suppresses by phone.
|
||||
if event.replace("-", "_") in {"sms_opt_out", "opt_out", "optout"}:
|
||||
phone = (
|
||||
if event in _SMS_OPT_OUT_EVENTS:
|
||||
phone = _as_str(
|
||||
payload.get("destination_number")
|
||||
or payload.get("from")
|
||||
or payload.get("source_number")
|
||||
or ""
|
||||
)
|
||||
if phone:
|
||||
from messaging.services import record_sms_stop
|
||||
|
||||
record_sms_stop(str(phone))
|
||||
record_sms_stop(phone)
|
||||
logger.info(
|
||||
"SMTP2GO SMS webhook unmatched event=%s phone=%s message_id=%s",
|
||||
event,
|
||||
@@ -503,23 +519,102 @@ def process_smtp2go_sms_webhook(payload: dict[str, Any]) -> ProviderEvent | None
|
||||
return ProviderEvent.objects.create(
|
||||
message=message,
|
||||
provider=PROVIDER_SMS,
|
||||
event_type=event,
|
||||
payload=payload,
|
||||
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)."""
|
||||
"""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 = (
|
||||
text = _as_str(
|
||||
payload.get("text")
|
||||
or payload.get("message")
|
||||
or payload.get("message_content")
|
||||
or ""
|
||||
).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"))
|
||||
)
|
||||
text = str(text).strip().upper()
|
||||
return text in {"STOP", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"}
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user