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
+54 -56
View File
@@ -39,7 +39,7 @@ from messaging.webhooks import (
PROVIDER_PCM,
PROVIDER_SMS,
campaign_engagement_stats,
is_inbound_sms_stop,
classify_smtp2go_payload,
parse_webhook_payload,
process_pcm_postcard_webhook,
process_smtp2go_email_webhook,
@@ -248,13 +248,13 @@ def _events_provider_filter(campaign: Campaign) -> tuple[list[str], str, str]:
[PROVIDER_SMS],
"Recent SMTP2GO events",
"No webhook events yet. SMTP2GO must POST SMS events to "
"<code>/portal/messaging/webhooks/sms/</code>.",
"<code>/portal/messaging/webhooks/smtp2go/</code>.",
)
return (
[PROVIDER_EMAIL],
"Recent SMTP2GO events",
"No webhook events yet. SMTP2GO must POST opens/clicks to "
"<code>/portal/messaging/webhooks/email/</code> "
"<code>/portal/messaging/webhooks/smtp2go/</code> "
"(see messaging README). SMTP2GOs own “Clicked” feed does not fill "
"this table by itself.",
)
@@ -863,33 +863,44 @@ def postcard_webhook(request):
@csrf_exempt
@require_POST
def sms_webhook(request):
def smtp2go_webhook(request):
"""
SMTP2GO SMS webhook — delivery status events + inbound STOP replies.
Unified SMTP2GO webhook — email + SMS events + inbound STOP replies.
Configure a *separate* webhook in SMTP2GO → Settings → Webhooks:
URL: https://<host>/portal/messaging/webhooks/sms/
One SMTP2GO webhook URL (paid plans cap at 10 webhooks):
URL: https://<host>/portal/messaging/webhooks/smtp2go/
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
Output type: JSON
SMS events: Submitted, Sending, Delivered, Failed, Rejected, Opt-out
(leave Email events unchecked on this webhook)
Users: email SMTP user(s) *and* the SMS API key used to send
Email events: all delivery/engagement boxes
Email headers: X-Monica-Message-Id
SMS events: Submitted, Sending, Delivered, Failed, Rejected (Opt-out if shown)
Inbound gateway POSTs without ``event`` (text=STOP, from=…) still opt out.
Legacy aliases ``/webhooks/email/`` and ``/webhooks/sms/`` hit this same view.
Payload shape selects the processor (email vs sms_* vs inbound STOP).
"""
channel = "sms"
_log_webhook_request(request, channel=channel)
# Log/auth before parsing so request.body stays readable (HMAC + Grafana dump).
_log_webhook_request(request, channel="smtp2go")
if not _webhook_authorized(
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
):
_log_webhook_auth_failed(request, channel=channel)
_log_webhook_auth_failed(request, channel="smtp2go")
return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request)
if not payload:
payload = request.POST.dict() or {}
# Inbound reply (STOP) — different payload shape than delivery events.
if is_inbound_sms_stop(payload):
kind = classify_smtp2go_payload(payload)
channel = {
"sms_inbound": "sms",
"sms": "sms",
"email": "email",
}.get(kind, "smtp2go")
logger.info("webhook_classified channel=%s kind=%s", channel, kind)
if kind == "sms_inbound":
phone = (
payload.get("from")
or payload.get("phone")
@@ -904,56 +915,43 @@ def sms_webhook(request):
stopped,
phone,
)
return JsonResponse({"ok": True, "opt_out": stopped})
return JsonResponse({"ok": True, "channel": "sms", "opt_out": stopped})
if kind == "unknown":
logger.warning(
"webhook_unmatched channel=smtp2go unrecognized_payload keys=%s",
sorted(str(k) for k in payload.keys()),
)
return JsonResponse(
{"ok": False, "error": "unrecognized_payload", "channel": None},
status=200,
)
try:
event = process_smtp2go_sms_webhook(payload)
except Exception as exc: # noqa: BLE001
logger.exception("SMTP2GO SMS webhook processing failed")
_log_webhook_result(channel=channel, error=str(exc))
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
_log_webhook_result(channel=channel, event=event)
return JsonResponse(
{
"ok": True,
"matched": bool(event and event.message_id),
"event_id": event.pk if event else None,
}
)
@csrf_exempt
@require_POST
def email_webhook(request):
"""
SMTP2GO email event webhook (delivered / open / click / bounce / …).
Configure in SMTP2GO → Settings → Webhooks:
URL: https://<host>/portal/messaging/webhooks/email/
Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET
Output type: JSON
Email events: all delivery/engagement boxes
Email headers: X-Monica-Message-Id
"""
channel = "email"
_log_webhook_request(request, channel=channel)
if not _webhook_authorized(
request, secret=settings.SMTP2GO_WEBHOOK_SECRET or ""
):
_log_webhook_auth_failed(request, channel=channel)
return HttpResponseForbidden("invalid webhook token")
payload = parse_webhook_payload(request)
try:
event = process_smtp2go_email_webhook(payload)
if kind == "sms":
event = process_smtp2go_sms_webhook(payload)
else:
event = process_smtp2go_email_webhook(payload)
except Exception as exc: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h)
logger.exception("SMTP2GO email webhook processing failed")
logger.exception("SMTP2GO %s webhook processing failed", kind)
_log_webhook_result(channel=channel, error=str(exc))
return JsonResponse({"ok": False, "error": "processing_failed"}, status=200)
return JsonResponse(
{"ok": False, "error": "processing_failed", "channel": channel},
status=200,
)
_log_webhook_result(channel=channel, event=event)
return JsonResponse(
{
"ok": True,
"channel": channel,
"matched": bool(event and event.message_id),
"event_id": event.pk if event else None,
}
)
# Legacy path names — same unified handler (keep SMTP2GO configs working).
email_webhook = smtp2go_webhook
sms_webhook = smtp2go_webhook