notifyfork 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- notifyfork/__init__.py +4 -0
- notifyfork/api/__init__.py +0 -0
- notifyfork/api/urls.py +5 -0
- notifyfork/api/webhooks/__init__.py +0 -0
- notifyfork/api/webhooks/resend_webhook.py +127 -0
- notifyfork/api/webhooks/sendgrid_webhook.py +124 -0
- notifyfork/api/webhooks/tasks.py +74 -0
- notifyfork/api/webhooks/twilio_webhook.py +121 -0
- notifyfork/api/webhooks/urls.py +10 -0
- notifyfork/client.py +63 -0
- notifyfork/core/__init__.py +0 -0
- notifyfork/core/application/__init__.py +0 -0
- notifyfork/core/application/dtos/__init__.py +0 -0
- notifyfork/core/application/dtos/send_notification_dto.py +25 -0
- notifyfork/core/application/interfaces/__init__.py +0 -0
- notifyfork/core/application/interfaces/notification_provider.py +43 -0
- notifyfork/core/application/interfaces/notification_repository.py +15 -0
- notifyfork/core/application/interfaces/template_repository.py +8 -0
- notifyfork/core/application/use_cases/__init__.py +0 -0
- notifyfork/core/application/use_cases/send_notification.py +91 -0
- notifyfork/core/domain/__init__.py +0 -0
- notifyfork/core/domain/entities/__init__.py +0 -0
- notifyfork/core/domain/entities/notification.py +141 -0
- notifyfork/core/domain/events/__init__.py +0 -0
- notifyfork/core/domain/events/notification_events.py +26 -0
- notifyfork/core/domain/value_objects/__init__.py +0 -0
- notifyfork/core/domain/value_objects/template.py +95 -0
- notifyfork/core/infrastructure/__init__.py +0 -0
- notifyfork/core/infrastructure/apps.py +7 -0
- notifyfork/core/infrastructure/container/__init__.py +3 -0
- notifyfork/core/infrastructure/container/providers.py +218 -0
- notifyfork/core/infrastructure/migrations/0001_initial.py +48 -0
- notifyfork/core/infrastructure/migrations/0002_seed_templates.py +116 -0
- notifyfork/core/infrastructure/migrations/0003_delivery_status.py +50 -0
- notifyfork/core/infrastructure/migrations/__init__.py +0 -0
- notifyfork/core/infrastructure/models/__init__.py +6 -0
- notifyfork/core/infrastructure/models/notification_model.py +98 -0
- notifyfork/core/infrastructure/providers/__init__.py +0 -0
- notifyfork/core/infrastructure/providers/firebase_provider.py +69 -0
- notifyfork/core/infrastructure/providers/resend_provider.py +83 -0
- notifyfork/core/infrastructure/providers/sendgrid_provider.py +150 -0
- notifyfork/core/infrastructure/providers/slack_provider.py +108 -0
- notifyfork/core/infrastructure/providers/smtp_provider.py +65 -0
- notifyfork/core/infrastructure/providers/twilio_provider.py +57 -0
- notifyfork/core/infrastructure/providers/whatsapp_provider.py +135 -0
- notifyfork/core/infrastructure/queue/__init__.py +0 -0
- notifyfork/core/infrastructure/queue/tasks.py +82 -0
- notifyfork/core/infrastructure/repositories/__init__.py +0 -0
- notifyfork/core/infrastructure/repositories/notification_repository.py +69 -0
- notifyfork/core/infrastructure/repositories/template_repository.py +23 -0
- notifyfork/shared/__init__.py +0 -0
- notifyfork/shared/exceptions/__init__.py +0 -0
- notifyfork/shared/exceptions/provider_exceptions.py +21 -0
- notifyfork/shared/logging/__init__.py +0 -0
- notifyfork/shared/logging/setup.py +38 -0
- notifyfork/shared/utils/__init__.py +0 -0
- notifyfork-0.1.2.dist-info/METADATA +599 -0
- notifyfork-0.1.2.dist-info/RECORD +60 -0
- notifyfork-0.1.2.dist-info/WHEEL +4 -0
- notifyfork-0.1.2.dist-info/licenses/LICENSE +21 -0
notifyfork/__init__.py
ADDED
|
File without changes
|
notifyfork/api/urls.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resend delivery event webhook.
|
|
3
|
+
|
|
4
|
+
Resend calls this endpoint when an email's status changes.
|
|
5
|
+
Configure in Resend Dashboard → Webhooks → add endpoint:
|
|
6
|
+
https://yourdomain.com/api/v1/webhooks/resend/events/
|
|
7
|
+
|
|
8
|
+
Resend sends one event per POST as JSON:
|
|
9
|
+
type — email.sent | email.delivered | email.delivery_delayed
|
|
10
|
+
| email.bounced | email.complained
|
|
11
|
+
data.email_id — the external ID we stored as provider_message_id
|
|
12
|
+
|
|
13
|
+
Security: Resend signs every request using Svix (svix-id / svix-timestamp /
|
|
14
|
+
svix-signature headers, HMAC-SHA256). We validate this before processing —
|
|
15
|
+
rejects spoofed requests. See:
|
|
16
|
+
https://resend.com/docs/dashboard/webhooks/verify-webhooks-requests
|
|
17
|
+
"""
|
|
18
|
+
import base64
|
|
19
|
+
import hashlib
|
|
20
|
+
import hmac
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
from django.conf import settings
|
|
24
|
+
from rest_framework import status
|
|
25
|
+
from rest_framework.request import Request
|
|
26
|
+
from rest_framework.response import Response
|
|
27
|
+
from rest_framework.views import APIView
|
|
28
|
+
|
|
29
|
+
from notifyfork.api.webhooks.tasks import process_delivery_update
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
RESEND_DELIVERED_EVENTS = {"email.delivered"}
|
|
34
|
+
RESEND_FAILED_EVENTS = {"email.bounced", "email.complained", "email.delivery_delayed"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ResendEventWebhookView(APIView):
|
|
38
|
+
"""
|
|
39
|
+
POST /api/v1/webhooks/resend/events/
|
|
40
|
+
|
|
41
|
+
Resend sends one event per request, signed via Svix headers.
|
|
42
|
+
Responds 200 immediately, enqueues the update to Celery.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
authentication_classes = [] # Resend uses signature, not token auth
|
|
46
|
+
permission_classes = []
|
|
47
|
+
|
|
48
|
+
def post(self, request: Request) -> Response:
|
|
49
|
+
if not self._validate_signature(request):
|
|
50
|
+
logger.warning("Invalid Resend signature — possible spoofed webhook", extra={
|
|
51
|
+
"ip": request.META.get("REMOTE_ADDR"),
|
|
52
|
+
})
|
|
53
|
+
return Response({"detail": "Invalid signature"}, status=status.HTTP_403_FORBIDDEN)
|
|
54
|
+
|
|
55
|
+
event_type = request.data.get("type", "")
|
|
56
|
+
data = request.data.get("data") or {}
|
|
57
|
+
email_id = data.get("email_id")
|
|
58
|
+
|
|
59
|
+
if not email_id:
|
|
60
|
+
return Response({"detail": "Missing data.email_id"}, status=status.HTTP_400_BAD_REQUEST)
|
|
61
|
+
|
|
62
|
+
logger.info("Resend webhook received", extra={"type": event_type, "email_id": email_id})
|
|
63
|
+
|
|
64
|
+
# Resend also sends intermediate events (sent, opened, clicked) —
|
|
65
|
+
# we only care about the final delivery outcome.
|
|
66
|
+
if event_type not in RESEND_DELIVERED_EVENTS | RESEND_FAILED_EVENTS:
|
|
67
|
+
return Response({"detail": "intermediate event, ignored"}, status=status.HTTP_200_OK)
|
|
68
|
+
|
|
69
|
+
notification_id = self._find_notification_id(email_id)
|
|
70
|
+
if not notification_id:
|
|
71
|
+
# Could be a message sent outside NotifyFork — not an error
|
|
72
|
+
logger.info("No notification found for email_id", extra={"email_id": email_id})
|
|
73
|
+
return Response({"detail": "unknown message"}, status=status.HTTP_200_OK)
|
|
74
|
+
|
|
75
|
+
delivered = event_type in RESEND_DELIVERED_EVENTS
|
|
76
|
+
reason = data.get("reason") if not delivered else None
|
|
77
|
+
|
|
78
|
+
process_delivery_update.delay(
|
|
79
|
+
notification_id=str(notification_id),
|
|
80
|
+
provider="resend_email",
|
|
81
|
+
delivered=delivered,
|
|
82
|
+
reason=reason,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return Response({"detail": "accepted"}, status=status.HTTP_200_OK)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _validate_signature(request: Request) -> bool:
|
|
89
|
+
secret = getattr(settings, "RESEND_WEBHOOK_SECRET", None)
|
|
90
|
+
if not secret:
|
|
91
|
+
logger.warning("RESEND_WEBHOOK_SECRET not configured — skipping signature validation")
|
|
92
|
+
return True # dev mode only — never in production
|
|
93
|
+
|
|
94
|
+
svix_id = request.META.get("HTTP_SVIX_ID", "")
|
|
95
|
+
svix_timestamp = request.META.get("HTTP_SVIX_TIMESTAMP", "")
|
|
96
|
+
svix_signature = request.META.get("HTTP_SVIX_SIGNATURE", "")
|
|
97
|
+
|
|
98
|
+
if not all([svix_id, svix_timestamp, svix_signature]):
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
secret_bytes = base64.b64decode(secret.removeprefix("whsec_"))
|
|
102
|
+
signed_content = f"{svix_id}.{svix_timestamp}.{request.body.decode()}"
|
|
103
|
+
expected = base64.b64encode(
|
|
104
|
+
hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
|
|
105
|
+
).decode()
|
|
106
|
+
|
|
107
|
+
# svix-signature can carry multiple "v1,<sig>" pairs (key rotation) — accept any match
|
|
108
|
+
candidates = [part.split(",", 1)[1] for part in svix_signature.split() if "," in part]
|
|
109
|
+
return any(hmac.compare_digest(expected, candidate) for candidate in candidates)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _find_notification_id(email_id: str) -> str | None:
|
|
113
|
+
"""
|
|
114
|
+
Looks up notification by the provider_message_id we stored at send time.
|
|
115
|
+
Single indexed query — provider_message_id should be indexed in production.
|
|
116
|
+
"""
|
|
117
|
+
import asyncio
|
|
118
|
+
from notifyfork.core.infrastructure.models.notification_model import NotificationModel
|
|
119
|
+
|
|
120
|
+
async def _get():
|
|
121
|
+
try:
|
|
122
|
+
obj = await NotificationModel.objects.aget(provider_message_id=email_id)
|
|
123
|
+
return str(obj.id)
|
|
124
|
+
except NotificationModel.DoesNotExist:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
return asyncio.get_event_loop().run_until_complete(_get())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SendGrid delivery event webhook.
|
|
3
|
+
|
|
4
|
+
SendGrid batches multiple events in one POST as a JSON array.
|
|
5
|
+
Configure in SendGrid → Settings → Mail Settings → Event Webhook:
|
|
6
|
+
https://yourdomain.com/api/v1/webhooks/sendgrid/events/
|
|
7
|
+
|
|
8
|
+
Each event in the array has:
|
|
9
|
+
event — processed | delivered | bounce | dropped | spamreport | unsubscribe
|
|
10
|
+
sg_message_id — the external ID we stored as provider_message_id
|
|
11
|
+
reason — present on bounce/drop events
|
|
12
|
+
|
|
13
|
+
Security: SendGrid signs requests with a public key (Ed25519).
|
|
14
|
+
We verify the signature using the public key from your SendGrid account.
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from django.conf import settings
|
|
20
|
+
from rest_framework import status
|
|
21
|
+
from rest_framework.request import Request
|
|
22
|
+
from rest_framework.response import Response
|
|
23
|
+
from rest_framework.views import APIView
|
|
24
|
+
|
|
25
|
+
from notifyfork.api.webhooks.tasks import process_delivery_update
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
SENDGRID_DELIVERED_EVENTS = {"delivered"}
|
|
30
|
+
SENDGRID_FAILED_EVENTS = {"bounce", "dropped", "spamreport"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SendGridEventWebhookView(APIView):
|
|
34
|
+
"""
|
|
35
|
+
POST /api/v1/webhooks/sendgrid/events/
|
|
36
|
+
|
|
37
|
+
SendGrid sends a JSON array of events — can be many at once.
|
|
38
|
+
We process each terminal event independently.
|
|
39
|
+
Responds 200 immediately, enqueues each update to Celery.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
authentication_classes = []
|
|
43
|
+
permission_classes = []
|
|
44
|
+
|
|
45
|
+
def post(self, request: Request) -> Response:
|
|
46
|
+
if not self._validate_signature(request):
|
|
47
|
+
logger.warning("Invalid SendGrid signature", extra={
|
|
48
|
+
"ip": request.META.get("REMOTE_ADDR"),
|
|
49
|
+
})
|
|
50
|
+
return Response({"detail": "Invalid signature"}, status=status.HTTP_403_FORBIDDEN)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
events = request.data if isinstance(request.data, list) else json.loads(request.body)
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
return Response({"detail": "Invalid JSON"}, status=status.HTTP_400_BAD_REQUEST)
|
|
56
|
+
|
|
57
|
+
queued = 0
|
|
58
|
+
for event in events:
|
|
59
|
+
event_type = event.get("event", "").lower()
|
|
60
|
+
sg_message_id = event.get("sg_message_id", "").split(".")[0] # strip suffix
|
|
61
|
+
reason = event.get("reason") or event.get("type")
|
|
62
|
+
|
|
63
|
+
if event_type not in SENDGRID_DELIVERED_EVENTS | SENDGRID_FAILED_EVENTS:
|
|
64
|
+
continue # intermediate events (open, click, etc) — skip
|
|
65
|
+
|
|
66
|
+
notification_id = self._find_notification_id(sg_message_id)
|
|
67
|
+
if not notification_id:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
logger.info("SendGrid event received", extra={
|
|
71
|
+
"event": event_type,
|
|
72
|
+
"sg_message_id": sg_message_id,
|
|
73
|
+
"notification_id": notification_id,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
delivered = event_type in SENDGRID_DELIVERED_EVENTS
|
|
77
|
+
process_delivery_update.delay(
|
|
78
|
+
notification_id=notification_id,
|
|
79
|
+
provider="sendgrid_email",
|
|
80
|
+
delivered=delivered,
|
|
81
|
+
reason=reason,
|
|
82
|
+
)
|
|
83
|
+
queued += 1
|
|
84
|
+
|
|
85
|
+
return Response({"accepted": queued}, status=status.HTTP_200_OK)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _validate_signature(request: Request) -> bool:
|
|
89
|
+
"""
|
|
90
|
+
Validates Ed25519 signature from SendGrid.
|
|
91
|
+
Public key is in SendGrid → Settings → Mail Settings → Event Webhook.
|
|
92
|
+
"""
|
|
93
|
+
public_key = getattr(settings, "SENDGRID_WEBHOOK_PUBLIC_KEY", None)
|
|
94
|
+
if not public_key:
|
|
95
|
+
logger.warning("SENDGRID_WEBHOOK_PUBLIC_KEY not set — skipping validation (dev only)")
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
from sendgrid.helpers.eventwebhook import EventWebhook, EventWebhookHeader
|
|
100
|
+
ew = EventWebhook()
|
|
101
|
+
ec_public_key = ew.convert_public_key_to_ecdsa(public_key)
|
|
102
|
+
return ew.verify_signature(
|
|
103
|
+
ec_public_key,
|
|
104
|
+
request.body,
|
|
105
|
+
request.META.get(EventWebhookHeader.SIGNATURE, ""),
|
|
106
|
+
request.META.get(EventWebhookHeader.TIMESTAMP, ""),
|
|
107
|
+
)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error("SendGrid signature validation error", extra={"error": str(e)})
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _find_notification_id(sg_message_id: str) -> str | None:
|
|
114
|
+
import asyncio
|
|
115
|
+
from notifyfork.core.infrastructure.models.notification_model import NotificationModel
|
|
116
|
+
|
|
117
|
+
async def _get():
|
|
118
|
+
try:
|
|
119
|
+
obj = await NotificationModel.objects.aget(provider_message_id=sg_message_id)
|
|
120
|
+
return str(obj.id)
|
|
121
|
+
except NotificationModel.DoesNotExist:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return asyncio.get_event_loop().run_until_complete(_get())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Celery tasks for processing provider delivery callbacks.
|
|
3
|
+
|
|
4
|
+
Design: webhook endpoints respond 200 immediately, then enqueue here.
|
|
5
|
+
This prevents provider retries caused by slow processing, and keeps
|
|
6
|
+
the webhook handler stateless and fast.
|
|
7
|
+
"""
|
|
8
|
+
import logging
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from celery import shared_task
|
|
12
|
+
from celery.utils.log import get_task_logger
|
|
13
|
+
|
|
14
|
+
logger = get_task_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@shared_task(bind=True, max_retries=3, default_retry_delay=10, acks_late=True)
|
|
18
|
+
def process_delivery_update(
|
|
19
|
+
self,
|
|
20
|
+
notification_id: str,
|
|
21
|
+
provider: str,
|
|
22
|
+
delivered: bool,
|
|
23
|
+
reason: str | None = None,
|
|
24
|
+
) -> dict:
|
|
25
|
+
"""
|
|
26
|
+
Updates notification delivery status from provider webhook.
|
|
27
|
+
|
|
28
|
+
Runs async so the webhook endpoint can respond instantly.
|
|
29
|
+
Idempotent — safe to run twice if the provider retries the webhook.
|
|
30
|
+
"""
|
|
31
|
+
from notifyfork.core.infrastructure.container.providers import Container
|
|
32
|
+
import asyncio
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
repository = Container.notification_repository()
|
|
36
|
+
uid = UUID(notification_id)
|
|
37
|
+
|
|
38
|
+
notification = asyncio.get_event_loop().run_until_complete(
|
|
39
|
+
repository.get_by_id(uid)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if not notification:
|
|
43
|
+
logger.warning("Webhook for unknown notification", extra={
|
|
44
|
+
"notification_id": notification_id, "provider": provider,
|
|
45
|
+
})
|
|
46
|
+
return {"status": "not_found"}
|
|
47
|
+
|
|
48
|
+
# Idempotency — if already terminal, skip
|
|
49
|
+
if notification.is_terminal:
|
|
50
|
+
logger.info("Notification already terminal, skipping webhook", extra={
|
|
51
|
+
"notification_id": notification_id,
|
|
52
|
+
"current_status": notification.status,
|
|
53
|
+
})
|
|
54
|
+
return {"status": "already_terminal"}
|
|
55
|
+
|
|
56
|
+
if delivered:
|
|
57
|
+
notification.mark_delivered()
|
|
58
|
+
else:
|
|
59
|
+
notification.mark_delivery_failed(reason or "Provider reported delivery failure")
|
|
60
|
+
|
|
61
|
+
asyncio.get_event_loop().run_until_complete(repository.save(notification))
|
|
62
|
+
|
|
63
|
+
logger.info("Delivery status updated", extra={
|
|
64
|
+
"notification_id": notification_id,
|
|
65
|
+
"delivered": delivered,
|
|
66
|
+
"status": notification.status,
|
|
67
|
+
})
|
|
68
|
+
return {"status": notification.status}
|
|
69
|
+
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
logger.error("Failed to process delivery update", extra={
|
|
72
|
+
"notification_id": notification_id, "error": str(exc),
|
|
73
|
+
})
|
|
74
|
+
raise self.retry(exc=exc)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Twilio delivery status webhook.
|
|
3
|
+
|
|
4
|
+
Twilio calls this endpoint when a message status changes.
|
|
5
|
+
Configure in Twilio Console → Phone Numbers → Status Callback URL:
|
|
6
|
+
https://yourdomain.com/api/v1/webhooks/twilio/status/
|
|
7
|
+
|
|
8
|
+
Twilio sends form-data (not JSON) with these key fields:
|
|
9
|
+
MessageSid — the external ID we stored as provider_message_id
|
|
10
|
+
MessageStatus — queued | sent | delivered | undelivered | failed
|
|
11
|
+
|
|
12
|
+
Security: Twilio signs every request with X-Twilio-Signature.
|
|
13
|
+
We validate this before processing — rejects spoofed requests.
|
|
14
|
+
"""
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
from django.conf import settings
|
|
18
|
+
from rest_framework import status
|
|
19
|
+
from rest_framework.parsers import FormParser, MultiPartParser
|
|
20
|
+
from rest_framework.request import Request
|
|
21
|
+
from rest_framework.response import Response
|
|
22
|
+
from rest_framework.views import APIView
|
|
23
|
+
from twilio.request_validator import RequestValidator
|
|
24
|
+
|
|
25
|
+
from notifyfork.api.webhooks.tasks import process_delivery_update
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Twilio statuses that mean "delivered to handset"
|
|
30
|
+
TWILIO_DELIVERED_STATUSES = {"delivered", "read"}
|
|
31
|
+
|
|
32
|
+
# Twilio statuses that mean "provider confirmed failure"
|
|
33
|
+
TWILIO_FAILED_STATUSES = {"failed", "undelivered"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TwilioStatusWebhookView(APIView):
|
|
37
|
+
"""
|
|
38
|
+
POST /api/v1/webhooks/twilio/status/
|
|
39
|
+
|
|
40
|
+
Validates Twilio signature, responds 200 immediately,
|
|
41
|
+
enqueues status update to Celery for async processing.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
authentication_classes = [] # Twilio uses signature, not token auth
|
|
45
|
+
permission_classes = []
|
|
46
|
+
parser_classes = [FormParser, MultiPartParser] # Twilio posts form-data, not JSON
|
|
47
|
+
|
|
48
|
+
def post(self, request: Request) -> Response:
|
|
49
|
+
if not self._validate_signature(request):
|
|
50
|
+
logger.warning("Invalid Twilio signature — possible spoofed webhook", extra={
|
|
51
|
+
"ip": request.META.get("REMOTE_ADDR"),
|
|
52
|
+
})
|
|
53
|
+
return Response({"detail": "Invalid signature"}, status=status.HTTP_403_FORBIDDEN)
|
|
54
|
+
|
|
55
|
+
message_sid = request.data.get("MessageSid")
|
|
56
|
+
message_status = request.data.get("MessageStatus", "").lower()
|
|
57
|
+
error_code = request.data.get("ErrorCode")
|
|
58
|
+
error_message = request.data.get("ErrorMessage")
|
|
59
|
+
|
|
60
|
+
if not message_sid or not message_status:
|
|
61
|
+
return Response({"detail": "Missing MessageSid or MessageStatus"},
|
|
62
|
+
status=status.HTTP_400_BAD_REQUEST)
|
|
63
|
+
|
|
64
|
+
logger.info("Twilio webhook received", extra={
|
|
65
|
+
"message_sid": message_sid,
|
|
66
|
+
"message_status": message_status,
|
|
67
|
+
"error_code": error_code,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
# Twilio also sends intermediate statuses (queued, sending, sent) —
|
|
71
|
+
# we only care about the final delivery outcome.
|
|
72
|
+
if message_status not in TWILIO_DELIVERED_STATUSES | TWILIO_FAILED_STATUSES:
|
|
73
|
+
return Response({"detail": "intermediate status, ignored"}, status=status.HTTP_200_OK)
|
|
74
|
+
|
|
75
|
+
notification_id = self._find_notification_id(message_sid)
|
|
76
|
+
if not notification_id:
|
|
77
|
+
# Could be a message sent outside NotifyFork — not an error
|
|
78
|
+
logger.info("No notification found for MessageSid", extra={"message_sid": message_sid})
|
|
79
|
+
return Response({"detail": "unknown message"}, status=status.HTTP_200_OK)
|
|
80
|
+
|
|
81
|
+
delivered = message_status in TWILIO_DELIVERED_STATUSES
|
|
82
|
+
reason = f"Twilio [{error_code}]: {error_message}" if error_code else None
|
|
83
|
+
|
|
84
|
+
process_delivery_update.delay(
|
|
85
|
+
notification_id=str(notification_id),
|
|
86
|
+
provider="twilio_sms",
|
|
87
|
+
delivered=delivered,
|
|
88
|
+
reason=reason,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return Response({"detail": "accepted"}, status=status.HTTP_200_OK)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _validate_signature(request: Request) -> bool:
|
|
95
|
+
auth_token = getattr(settings, "TWILIO_AUTH_TOKEN", None)
|
|
96
|
+
if not auth_token:
|
|
97
|
+
logger.error("TWILIO_AUTH_TOKEN not configured — skipping signature validation")
|
|
98
|
+
return True # dev mode only — never in production
|
|
99
|
+
|
|
100
|
+
validator = RequestValidator(auth_token)
|
|
101
|
+
signature = request.META.get("HTTP_X_TWILIO_SIGNATURE", "")
|
|
102
|
+
url = request.build_absolute_uri()
|
|
103
|
+
return validator.validate(url, request.data, signature)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _find_notification_id(message_sid: str) -> str | None:
|
|
107
|
+
"""
|
|
108
|
+
Looks up notification by the provider_message_id we stored at send time.
|
|
109
|
+
Single indexed query — provider_message_id should be indexed in production.
|
|
110
|
+
"""
|
|
111
|
+
import asyncio
|
|
112
|
+
from notifyfork.core.infrastructure.models.notification_model import NotificationModel
|
|
113
|
+
|
|
114
|
+
async def _get():
|
|
115
|
+
try:
|
|
116
|
+
obj = await NotificationModel.objects.aget(provider_message_id=message_sid)
|
|
117
|
+
return str(obj.id)
|
|
118
|
+
except NotificationModel.DoesNotExist:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
return asyncio.get_event_loop().run_until_complete(_get())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
from notifyfork.api.webhooks.twilio_webhook import TwilioStatusWebhookView
|
|
3
|
+
from notifyfork.api.webhooks.sendgrid_webhook import SendGridEventWebhookView
|
|
4
|
+
from notifyfork.api.webhooks.resend_webhook import ResendEventWebhookView
|
|
5
|
+
|
|
6
|
+
urlpatterns = [
|
|
7
|
+
path("twilio/status/", TwilioStatusWebhookView.as_view(), name="webhook-twilio-status"),
|
|
8
|
+
path("sendgrid/events/", SendGridEventWebhookView.as_view(), name="webhook-sendgrid-events"),
|
|
9
|
+
path("resend/events/", ResendEventWebhookView.as_view(), name="webhook-resend-events"),
|
|
10
|
+
]
|
notifyfork/client.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python entry point for NotifyFork.
|
|
3
|
+
|
|
4
|
+
Use this when the caller lives in the same project that has NotifyFork
|
|
5
|
+
installed. For a different service to send events, expose your own
|
|
6
|
+
authenticated view that calls this function — see the README.
|
|
7
|
+
"""
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from celery.result import AsyncResult
|
|
12
|
+
|
|
13
|
+
from notifyfork.core.domain.entities.notification import NotificationChannel, NotificationType
|
|
14
|
+
from notifyfork.core.infrastructure.queue.tasks import dispatch_notification
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
__all__ = ["send"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def send(
|
|
22
|
+
recipient: str,
|
|
23
|
+
channel: NotificationChannel | str,
|
|
24
|
+
template_id: str,
|
|
25
|
+
notification_type: NotificationType | str,
|
|
26
|
+
context: dict[str, Any] | None = None,
|
|
27
|
+
max_attempts: int = 3,
|
|
28
|
+
) -> AsyncResult:
|
|
29
|
+
"""
|
|
30
|
+
Enqueues a notification for delivery.
|
|
31
|
+
|
|
32
|
+
You already know the channel and template you want, so this just
|
|
33
|
+
passes them straight to the queue — no routing table or event
|
|
34
|
+
catalog to register first.
|
|
35
|
+
|
|
36
|
+
Raises ValueError if recipient is blank or max_attempts is out of
|
|
37
|
+
range (1-5).
|
|
38
|
+
"""
|
|
39
|
+
if not recipient or not recipient.strip():
|
|
40
|
+
raise ValueError("recipient cannot be blank")
|
|
41
|
+
if not 1 <= max_attempts <= 5:
|
|
42
|
+
raise ValueError("max_attempts must be between 1 and 5")
|
|
43
|
+
|
|
44
|
+
task = dispatch_notification.delay({
|
|
45
|
+
"recipient": recipient.strip(),
|
|
46
|
+
"channel": channel,
|
|
47
|
+
"notification_type": notification_type,
|
|
48
|
+
"template_id": template_id,
|
|
49
|
+
"context": context or {},
|
|
50
|
+
"max_attempts": max_attempts,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
logger.info(
|
|
54
|
+
"Notification accepted and enqueued",
|
|
55
|
+
extra={
|
|
56
|
+
"channel": channel,
|
|
57
|
+
"template_id": template_id,
|
|
58
|
+
"task_id": task.id,
|
|
59
|
+
"recipient_hint": recipient[:6] + "***", # never log full PII
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return task
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from pydantic import BaseModel, field_validator
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SendNotificationDTO(BaseModel):
|
|
6
|
+
recipient: str
|
|
7
|
+
channel: str
|
|
8
|
+
notification_type: str
|
|
9
|
+
template_id: str
|
|
10
|
+
context: dict[str, Any] = {}
|
|
11
|
+
max_attempts: int = 3
|
|
12
|
+
|
|
13
|
+
@field_validator("recipient")
|
|
14
|
+
@classmethod
|
|
15
|
+
def recipient_not_empty(cls, v: str) -> str:
|
|
16
|
+
if not v.strip():
|
|
17
|
+
raise ValueError("Recipient cannot be empty")
|
|
18
|
+
return v.strip()
|
|
19
|
+
|
|
20
|
+
@field_validator("max_attempts")
|
|
21
|
+
@classmethod
|
|
22
|
+
def valid_attempts(cls, v: int) -> int:
|
|
23
|
+
if not 1 <= v <= 5:
|
|
24
|
+
raise ValueError("max_attempts must be between 1 and 5")
|
|
25
|
+
return v
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from notifyfork.core.domain.entities.notification import NotificationChannel
|
|
6
|
+
from notifyfork.core.domain.value_objects.template import NotificationTemplate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ProviderResult:
|
|
11
|
+
success: bool
|
|
12
|
+
provider_name: str
|
|
13
|
+
external_id: str | None = None
|
|
14
|
+
error: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NotificationProvider(ABC):
|
|
18
|
+
"""
|
|
19
|
+
Base contract every provider must implement.
|
|
20
|
+
|
|
21
|
+
send_with_template() is the unified entry point.
|
|
22
|
+
The provider decides internally whether to render locally
|
|
23
|
+
or delegate to an external template system.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def name(self) -> str: ...
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def supported_channels(self) -> list[NotificationChannel | str]: ...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def send_with_template(
|
|
36
|
+
self,
|
|
37
|
+
recipient: str,
|
|
38
|
+
template: NotificationTemplate,
|
|
39
|
+
context: dict[str, Any],
|
|
40
|
+
) -> ProviderResult: ...
|
|
41
|
+
|
|
42
|
+
def supports(self, channel: NotificationChannel | str) -> bool:
|
|
43
|
+
return channel in self.supported_channels
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from notifyfork.core.domain.entities.notification import Notification
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NotificationRepository(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
async def save(self, notification: Notification) -> None: ...
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def get_by_id(self, notification_id: UUID) -> Notification | None: ...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def get_pending_retries(self, limit: int = 100) -> list[Notification]: ...
|
|
File without changes
|