Coverage for apps/communications/webhooks.py: 90%
78 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-04 17:24 +0000
1"""SendGrid Event Webhook: delivery and open tracking for outbound email (add-sendgrid-email).
3SendGrid POSTs batches of events (processed, delivered, open, click, bounce, dropped, …) to
4``/api/webhooks/sendgrid``. Each batch is verified with SendGrid's Signed Event Webhook key
5(``SENDGRID_WEBHOOK_PUBLIC_KEY``); each event is matched to our ``Communication`` by the
6``comm_id`` custom arg (set when sending) or by SendGrid's message id, recorded as a
7``CommunicationEvent`` (once — by ``sg_event_id``) and moves the message status forward.
8"""
10from __future__ import annotations
12import json
13import logging
14from datetime import UTC, datetime
16from django.conf import settings
17from django.db import transaction
18from ninja import Router
20from apps.communications.models import Communication, CommunicationEvent
21from apps.communications.status import EVENT_TO_STATUS, advance_status
22from domain.result import AppError, DomainError
24logger = logging.getLogger(__name__)
26router = Router(tags=["webhooks"])
28# SendGrid event name → our event type. Event types without a status change (deferred, spam
29# report, unsubscribe) are still recorded on the timeline.
30SENDGRID_EVENTS = {
31 "processed": "sent",
32 "delivered": "delivered",
33 "open": "opened",
34 "click": "clicked",
35 "bounce": "bounced",
36 "dropped": "failed",
37 "deferred": "deferred",
38 "spamreport": "spam_report",
39 "unsubscribe": "unsubscribed",
40 "group_unsubscribe": "unsubscribed",
41}
44def _verify(request) -> None:
45 public_key = settings.SENDGRID_WEBHOOK_PUBLIC_KEY
46 if not public_key:
47 raise DomainError([AppError("NOT_FOUND", "SendGrid webhook is not configured.")])
48 from sendgrid.helpers.eventwebhook import EventWebhook, EventWebhookHeader
50 signature = request.headers.get(EventWebhookHeader.SIGNATURE, "")
51 timestamp = request.headers.get(EventWebhookHeader.TIMESTAMP, "")
52 try:
53 webhook = EventWebhook()
54 key = webhook.convert_public_key_to_ecdsa(public_key)
55 valid = webhook.verify_signature(request.body.decode("utf-8"), signature, timestamp, key)
56 except Exception: # malformed key / signature
57 valid = False
58 if not valid:
59 raise DomainError([AppError("FORBIDDEN", "Invalid webhook signature.")])
62def _find_communication(event: dict) -> Communication | None:
63 comm_id = str(event.get("comm_id") or "")
64 if comm_id.isdigit():
65 comm = Communication.objects.filter(pk=int(comm_id), channel="email").first()
66 if comm is not None:
67 return comm
68 sg_message_id = str(event.get("sg_message_id") or "")
69 if sg_message_id:
70 # sg_message_id = "<X-Message-Id>.filter…": match on the id we stored when sending.
71 prefix = sg_message_id.split(".", 1)[0]
72 return Communication.objects.filter(provider_message_id=prefix).first()
73 return None
76def record_sendgrid_event(event: dict) -> bool:
77 """Apply one SendGrid event. Returns True when it was recorded (False: unknown event type,
78 unknown message, or a duplicate)."""
79 event_type = SENDGRID_EVENTS.get(str(event.get("event") or ""))
80 if event_type is None:
81 return False
82 comm = _find_communication(event)
83 if comm is None:
84 return False
85 sg_event_id = event.get("sg_event_id") or None
86 if sg_event_id and CommunicationEvent.objects.filter(provider_event_id=sg_event_id).exists():
87 return False
88 occurred_at = None
89 if isinstance(event.get("timestamp"), int | float):
90 occurred_at = datetime.fromtimestamp(event["timestamp"], tz=UTC)
91 with transaction.atomic():
92 CommunicationEvent.objects.create(
93 communication=comm,
94 event_type=event_type,
95 provider_event_id=sg_event_id,
96 occurred_at=occurred_at,
97 )
98 new_status = EVENT_TO_STATUS.get(event_type)
99 if new_status: 99 ↛ 104line 99 didn't jump to line 104
100 advanced = advance_status(comm.status, new_status)
101 if advanced != comm.status: 101 ↛ 104line 101 didn't jump to line 104
102 comm.status = advanced
103 comm.save(update_fields=["status", "updated_at"])
104 return True
107@router.post("/webhooks/sendgrid", auth=None)
108def sendgrid_events(request):
109 """Public endpoint for SendGrid's Event Webhook (signature-verified, no login)."""
110 _verify(request)
111 try:
112 events = json.loads(request.body or b"[]")
113 except ValueError as exc:
114 raise DomainError([AppError("VALIDATION", "Body must be a JSON array of events.")]) from exc
115 if not isinstance(events, list): 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 raise DomainError([AppError("VALIDATION", "Body must be a JSON array of events.")])
117 recorded = 0
118 for event in events:
119 if isinstance(event, dict): 119 ↛ 118line 119 didn't jump to line 118 because the condition on line 119 was always true
120 try:
121 recorded += record_sendgrid_event(event)
122 except Exception: # one bad event must not make SendGrid retry the whole batch
123 logger.exception("SendGrid event could not be recorded: %s", event.get("sg_event_id"))
124 return {"received": len(events), "recorded": recorded}