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:
+54
-56
@@ -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). SMTP2GO’s 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user