Unify SMTP2GO email and SMS into one webhook URL.
Deploy Beta / unit-tests (push) Successful in 13s
Deploy Beta / docker (push) Successful in 18s
Deploy Beta / deploy-beta (push) Successful in 1m38s

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:
2026-08-10 11:06:37 -05:00
parent f7f3174e1c
commit ee28dcabab
7 changed files with 472 additions and 115 deletions
+128 -33
View File
@@ -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: