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.
Files changed (60) hide show
  1. notifyfork/__init__.py +4 -0
  2. notifyfork/api/__init__.py +0 -0
  3. notifyfork/api/urls.py +5 -0
  4. notifyfork/api/webhooks/__init__.py +0 -0
  5. notifyfork/api/webhooks/resend_webhook.py +127 -0
  6. notifyfork/api/webhooks/sendgrid_webhook.py +124 -0
  7. notifyfork/api/webhooks/tasks.py +74 -0
  8. notifyfork/api/webhooks/twilio_webhook.py +121 -0
  9. notifyfork/api/webhooks/urls.py +10 -0
  10. notifyfork/client.py +63 -0
  11. notifyfork/core/__init__.py +0 -0
  12. notifyfork/core/application/__init__.py +0 -0
  13. notifyfork/core/application/dtos/__init__.py +0 -0
  14. notifyfork/core/application/dtos/send_notification_dto.py +25 -0
  15. notifyfork/core/application/interfaces/__init__.py +0 -0
  16. notifyfork/core/application/interfaces/notification_provider.py +43 -0
  17. notifyfork/core/application/interfaces/notification_repository.py +15 -0
  18. notifyfork/core/application/interfaces/template_repository.py +8 -0
  19. notifyfork/core/application/use_cases/__init__.py +0 -0
  20. notifyfork/core/application/use_cases/send_notification.py +91 -0
  21. notifyfork/core/domain/__init__.py +0 -0
  22. notifyfork/core/domain/entities/__init__.py +0 -0
  23. notifyfork/core/domain/entities/notification.py +141 -0
  24. notifyfork/core/domain/events/__init__.py +0 -0
  25. notifyfork/core/domain/events/notification_events.py +26 -0
  26. notifyfork/core/domain/value_objects/__init__.py +0 -0
  27. notifyfork/core/domain/value_objects/template.py +95 -0
  28. notifyfork/core/infrastructure/__init__.py +0 -0
  29. notifyfork/core/infrastructure/apps.py +7 -0
  30. notifyfork/core/infrastructure/container/__init__.py +3 -0
  31. notifyfork/core/infrastructure/container/providers.py +218 -0
  32. notifyfork/core/infrastructure/migrations/0001_initial.py +48 -0
  33. notifyfork/core/infrastructure/migrations/0002_seed_templates.py +116 -0
  34. notifyfork/core/infrastructure/migrations/0003_delivery_status.py +50 -0
  35. notifyfork/core/infrastructure/migrations/__init__.py +0 -0
  36. notifyfork/core/infrastructure/models/__init__.py +6 -0
  37. notifyfork/core/infrastructure/models/notification_model.py +98 -0
  38. notifyfork/core/infrastructure/providers/__init__.py +0 -0
  39. notifyfork/core/infrastructure/providers/firebase_provider.py +69 -0
  40. notifyfork/core/infrastructure/providers/resend_provider.py +83 -0
  41. notifyfork/core/infrastructure/providers/sendgrid_provider.py +150 -0
  42. notifyfork/core/infrastructure/providers/slack_provider.py +108 -0
  43. notifyfork/core/infrastructure/providers/smtp_provider.py +65 -0
  44. notifyfork/core/infrastructure/providers/twilio_provider.py +57 -0
  45. notifyfork/core/infrastructure/providers/whatsapp_provider.py +135 -0
  46. notifyfork/core/infrastructure/queue/__init__.py +0 -0
  47. notifyfork/core/infrastructure/queue/tasks.py +82 -0
  48. notifyfork/core/infrastructure/repositories/__init__.py +0 -0
  49. notifyfork/core/infrastructure/repositories/notification_repository.py +69 -0
  50. notifyfork/core/infrastructure/repositories/template_repository.py +23 -0
  51. notifyfork/shared/__init__.py +0 -0
  52. notifyfork/shared/exceptions/__init__.py +0 -0
  53. notifyfork/shared/exceptions/provider_exceptions.py +21 -0
  54. notifyfork/shared/logging/__init__.py +0 -0
  55. notifyfork/shared/logging/setup.py +38 -0
  56. notifyfork/shared/utils/__init__.py +0 -0
  57. notifyfork-0.1.2.dist-info/METADATA +599 -0
  58. notifyfork-0.1.2.dist-info/RECORD +60 -0
  59. notifyfork-0.1.2.dist-info/WHEEL +4 -0
  60. notifyfork-0.1.2.dist-info/licenses/LICENSE +21 -0
notifyfork/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from notifyfork.client import send
2
+ from notifyfork.core.infrastructure.container.providers import provider
3
+
4
+ __all__ = ["send", "provider"]
File without changes
notifyfork/api/urls.py ADDED
@@ -0,0 +1,5 @@
1
+ from django.urls import path, include
2
+
3
+ urlpatterns = [
4
+ path("webhooks/", include("notifyfork.api.webhooks.urls")),
5
+ ]
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]: ...
@@ -0,0 +1,8 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from notifyfork.core.domain.value_objects.template import NotificationTemplate
4
+
5
+
6
+ class TemplateRepository(ABC):
7
+ @abstractmethod
8
+ async def get_by_id(self, template_id: str) -> NotificationTemplate | None: ...
File without changes