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
@@ -0,0 +1,65 @@
1
+ import logging
2
+ import smtplib
3
+ from email.mime.multipart import MIMEMultipart
4
+ from email.mime.text import MIMEText
5
+ from typing import Any
6
+
7
+ from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
8
+ from notifyfork.core.domain.entities.notification import NotificationChannel
9
+ from notifyfork.core.domain.value_objects.template import NotificationTemplate
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SMTPEmailProvider(NotificationProvider):
15
+ """
16
+ Generic SMTP email provider — renders templates locally.
17
+
18
+ Always LOCAL mode. Body is HTML rendered here before sending.
19
+ For external template rendering (SendGrid, Mailgun), use their
20
+ dedicated providers instead.
21
+ """
22
+
23
+ def __init__(self, host: str, port: int, username: str, password: str, from_email: str) -> None:
24
+ self._host = host
25
+ self._port = port
26
+ self._username = username
27
+ self._password = password
28
+ self._from_email = from_email
29
+
30
+ @property
31
+ def name(self) -> str:
32
+ return "smtp_email"
33
+
34
+ @property
35
+ def supported_channels(self) -> list[NotificationChannel]:
36
+ # "email" — generic, eligible for fallback to sendgrid_email/resend_email.
37
+ # "smtp_email" (== self.name) — pins this exact vendor.
38
+ return [NotificationChannel.EMAIL, self.name]
39
+
40
+ async def send_with_template(
41
+ self,
42
+ recipient: str,
43
+ template: NotificationTemplate,
44
+ context: dict[str, Any],
45
+ ) -> ProviderResult:
46
+ body = template.render(context)
47
+ subject = template.render_subject(context) or "(no subject)"
48
+
49
+ try:
50
+ msg = MIMEMultipart("alternative")
51
+ msg["Subject"] = subject
52
+ msg["From"] = self._from_email
53
+ msg["To"] = recipient
54
+ msg.attach(MIMEText(body, "html"))
55
+
56
+ with smtplib.SMTP_SSL(self._host, self._port) as server:
57
+ server.login(self._username, self._password)
58
+ server.sendmail(self._from_email, recipient, msg.as_string())
59
+
60
+ logger.info("Email sent via SMTP", extra={"to": recipient, "subject": subject})
61
+ return ProviderResult(success=True, provider_name=self.name)
62
+
63
+ except smtplib.SMTPException as e:
64
+ logger.error("SMTP error", extra={"error": str(e)})
65
+ return ProviderResult(success=False, provider_name=self.name, error=str(e))
@@ -0,0 +1,57 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from twilio.rest import Client
5
+ from twilio.base.exceptions import TwilioRestException
6
+
7
+ from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
8
+ from notifyfork.core.domain.entities.notification import NotificationChannel
9
+ from notifyfork.core.domain.value_objects.template import NotificationTemplate, TemplateMode
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class TwilioSMSProvider(NotificationProvider):
15
+ """
16
+ Twilio SMS provider.
17
+
18
+ LOCAL mode — sends free-form text. Body is rendered locally.
19
+ EXTERNAL mode — not applicable for SMS (no template system).
20
+ """
21
+
22
+ def __init__(self, account_sid: str, auth_token: str, from_number: str) -> None:
23
+ self._client = Client(account_sid, auth_token)
24
+ self._from_number = from_number
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ return "twilio_sms"
29
+
30
+ @property
31
+ def supported_channels(self) -> list[NotificationChannel]:
32
+ # "sms" — generic, eligible for fallback if another SMS provider is
33
+ # registered. "twilio_sms" (== self.name) — pins this exact vendor.
34
+ return [NotificationChannel.SMS, self.name]
35
+
36
+ async def send_with_template(
37
+ self,
38
+ recipient: str,
39
+ template: NotificationTemplate,
40
+ context: dict[str, Any],
41
+ ) -> ProviderResult:
42
+ body = template.render(context)
43
+
44
+ try:
45
+ message = self._client.messages.create(
46
+ body=body,
47
+ from_=self._from_number,
48
+ to=recipient,
49
+ )
50
+ logger.info("SMS sent", extra={"sid": message.sid, "to": recipient[:6] + "***"})
51
+ return ProviderResult(success=True, provider_name=self.name, external_id=message.sid)
52
+
53
+ except TwilioRestException as e:
54
+ logger.error("Twilio SMS error", extra={"code": e.code, "msg": e.msg})
55
+ return ProviderResult(
56
+ success=False, provider_name=self.name, error=f"Twilio [{e.code}]: {e.msg}"
57
+ )
@@ -0,0 +1,135 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from twilio.rest import Client
5
+ from twilio.base.exceptions import TwilioRestException
6
+
7
+ from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
8
+ from notifyfork.core.domain.entities.notification import NotificationChannel
9
+ from notifyfork.core.domain.value_objects.template import NotificationTemplate, TemplateMode
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Current WhatsApp vendor: Twilio (via the official `twilio` SDK, hence no
14
+ # hardcoded _API_URL like sendgrid_provider.py — the SDK's Client resolves
15
+ # https://api.twilio.com internally).
16
+ #
17
+ # This is the only WhatsApp provider today, but not the only one planned —
18
+ # e.g. Evolution API is a likely next candidate. Follow the SendGrid/SMTP
19
+ # pattern for that: a new file (evolution_provider.py) with its own
20
+ # EvolutionWhatsAppProvider class, registered for the same
21
+ # NotificationChannel.WHATSAPP, ordered/failed-over via
22
+ # NOTIFYFORK_PROVIDER_ORDER — not a branch inside this class.
23
+
24
+
25
+ class TwilioWhatsAppProvider(NotificationProvider):
26
+ """
27
+ WhatsApp via Twilio.
28
+
29
+ LOCAL mode — sandbox/dev only. Sends free-form text.
30
+ EXTERNAL mode — production. Uses Meta-approved template SID + mapped variables.
31
+
32
+ channel="whatsapp" stays generic even with a second provider (e.g. a future
33
+ Evolution API integration) registered for the same NotificationChannel.WHATSAPP —
34
+ same pattern as SendGrid/SMTP on channel="email". But that automatic fallback is
35
+ only safe for LOCAL-mode templates. A template in EXTERNAL mode here holds a
36
+ Twilio Content SID, which only this provider understands — a LOCAL-only WhatsApp
37
+ provider (like SMTP is for email) can't fall back to it. Keep EXTERNAL templates
38
+ provider-specific; don't rely on channel fallback across template modes.
39
+
40
+ Twilio WhatsApp external templates use positional variables: "1", "2", "3"...
41
+ Use VariableMapping to translate your semantic context to positional keys:
42
+
43
+ VariableMapping({"name": "1", "code": "2"})
44
+
45
+ So context {"name": "Mario", "code": "847291"} becomes {"1": "Mario", "2": "847291"}
46
+ which is what Twilio's ContentTemplate API expects.
47
+ """
48
+
49
+ def __init__(self, account_sid: str, auth_token: str, from_number: str) -> None:
50
+ self._client = Client(account_sid, auth_token)
51
+ self._from_number = (
52
+ from_number if from_number.startswith("whatsapp:") else f"whatsapp:{from_number}"
53
+ )
54
+
55
+ @property
56
+ def name(self) -> str:
57
+ return "twilio_whatsapp"
58
+
59
+ @property
60
+ def supported_channels(self) -> list[NotificationChannel]:
61
+ # "whatsapp" — generic, eligible for automatic fallback if another
62
+ # WhatsApp provider (e.g. Evolution) is registered later.
63
+ # "twilio_whatsapp" (== self.name) — pins this exact vendor, no fallback.
64
+ return [NotificationChannel.WHATSAPP, self.name]
65
+
66
+ async def send_with_template(
67
+ self,
68
+ recipient: str,
69
+ template: NotificationTemplate,
70
+ context: dict[str, Any],
71
+ ) -> ProviderResult:
72
+ to = recipient if recipient.startswith("whatsapp:") else f"whatsapp:{recipient}"
73
+
74
+ if template.mode == TemplateMode.EXTERNAL:
75
+ return await self._send_external(to, template, context)
76
+ return await self._send_local(to, template, context)
77
+
78
+ async def _send_external(
79
+ self,
80
+ to: str,
81
+ template: NotificationTemplate,
82
+ context: dict[str, Any],
83
+ ) -> ProviderResult:
84
+ """
85
+ Sends using a Meta-approved Twilio Content Template.
86
+ Variables are translated via VariableMapping before dispatch.
87
+ """
88
+ translated = template.translate_variables(context)
89
+
90
+ logger.info(
91
+ "Sending WhatsApp via external template",
92
+ extra={
93
+ "template_sid": template.external_template_id,
94
+ "to": to[:12] + "***",
95
+ "variables": list(translated.keys()),
96
+ },
97
+ )
98
+
99
+ try:
100
+ message = self._client.messages.create(
101
+ content_sid=template.external_template_id,
102
+ content_variables=str(translated), # Twilio expects JSON string
103
+ from_=self._from_number,
104
+ to=to,
105
+ )
106
+ return ProviderResult(
107
+ success=True, provider_name=self.name, external_id=message.sid
108
+ )
109
+ except TwilioRestException as e:
110
+ logger.error("WhatsApp external template error", extra={"code": e.code, "msg": e.msg})
111
+ return ProviderResult(
112
+ success=False,
113
+ provider_name=self.name,
114
+ error=f"Twilio WhatsApp [{e.code}]: {e.msg}",
115
+ )
116
+
117
+ async def _send_local(
118
+ self,
119
+ to: str,
120
+ template: NotificationTemplate,
121
+ context: dict[str, Any],
122
+ ) -> ProviderResult:
123
+ """Free-form message — only valid in sandbox/dev."""
124
+ body = template.render(context)
125
+ try:
126
+ message = self._client.messages.create(body=body, from_=self._from_number, to=to)
127
+ return ProviderResult(
128
+ success=True, provider_name=self.name, external_id=message.sid
129
+ )
130
+ except TwilioRestException as e:
131
+ return ProviderResult(
132
+ success=False,
133
+ provider_name=self.name,
134
+ error=f"Twilio WhatsApp [{e.code}]: {e.msg}",
135
+ )
File without changes
@@ -0,0 +1,82 @@
1
+ import logging
2
+ from uuid import UUID
3
+
4
+ from celery import shared_task
5
+ from celery.utils.log import get_task_logger
6
+
7
+ logger = get_task_logger(__name__)
8
+
9
+
10
+ @shared_task(
11
+ bind=True,
12
+ max_retries=3,
13
+ default_retry_delay=60, # 1 min first retry
14
+ retry_backoff=True, # exponential: 1m, 2m, 4m
15
+ retry_backoff_max=600, # cap at 10 minutes
16
+ acks_late=True, # only ack after task completes — safe on worker crash
17
+ reject_on_worker_lost=True,
18
+ )
19
+ def dispatch_notification(self, payload: dict) -> dict:
20
+ """
21
+ Celery task that drives notification dispatch.
22
+
23
+ acks_late=True + reject_on_worker_lost=True ensures that if a
24
+ worker dies mid-execution, the task is re-queued instead of lost.
25
+
26
+ Retry strategy uses exponential backoff to avoid hammering
27
+ a provider that's temporarily down (thundering herd prevention).
28
+ """
29
+ from notifyfork.core.application.dtos.send_notification_dto import SendNotificationDTO
30
+ from notifyfork.core.infrastructure.container import Container
31
+
32
+ try:
33
+ dto = SendNotificationDTO(**payload)
34
+ use_case = Container.send_notification_use_case()
35
+
36
+ import asyncio
37
+ notification_id = asyncio.get_event_loop().run_until_complete(use_case.execute(dto))
38
+
39
+ logger.info("Task completed", extra={"notification_id": str(notification_id)})
40
+ return {"notification_id": str(notification_id), "status": "dispatched"}
41
+
42
+ except Exception as exc:
43
+ logger.warning(
44
+ "Task failed, scheduling retry",
45
+ extra={"attempt": self.request.retries + 1, "error": str(exc)},
46
+ )
47
+ raise self.retry(exc=exc)
48
+
49
+
50
+ @shared_task
51
+ def retry_pending_notifications() -> dict:
52
+ """
53
+ Periodic task (beat) that picks up notifications stuck in RETRYING state.
54
+
55
+ Prevents silent failures from getting lost if the worker
56
+ crashed before persisting the final status.
57
+
58
+ Schedule this via Celery Beat — e.g. every 5 minutes.
59
+ """
60
+ from notifyfork.core.infrastructure.container import Container
61
+
62
+ repository = Container.notification_repository()
63
+
64
+ import asyncio
65
+ pending = asyncio.get_event_loop().run_until_complete(
66
+ repository.get_pending_retries(limit=100)
67
+ )
68
+
69
+ queued = 0
70
+ for notification in pending:
71
+ if notification.can_retry:
72
+ dispatch_notification.delay({
73
+ "recipient": notification.recipient,
74
+ "channel": notification.channel,
75
+ "notification_type": notification.notification_type,
76
+ "template_id": notification.template_id,
77
+ "context": notification.context,
78
+ })
79
+ queued += 1
80
+
81
+ logger.info("Retry sweep complete", extra={"queued": queued})
82
+ return {"retried": queued}
@@ -0,0 +1,69 @@
1
+ import logging
2
+ from uuid import UUID
3
+
4
+ from notifyfork.core.application.interfaces.notification_repository import NotificationRepository
5
+ from notifyfork.core.domain.entities.notification import Notification, NotificationStatus
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class DjangoNotificationRepository(NotificationRepository):
11
+
12
+ async def save(self, notification: Notification) -> None:
13
+ from notifyfork.core.infrastructure.models.notification_model import NotificationModel
14
+ await NotificationModel.objects.aupdate_or_create(
15
+ id=notification.id,
16
+ defaults={
17
+ "recipient": notification.recipient,
18
+ "channel": notification.channel,
19
+ "notification_type": notification.notification_type,
20
+ "template_id": notification.template_id,
21
+ "context": notification.context,
22
+ "status": notification.status.value,
23
+ "provider_used": notification.provider_used,
24
+ "attempts": notification.attempts,
25
+ "max_attempts": notification.max_attempts,
26
+ "error_detail": notification.error_detail,
27
+ "sent_at": notification.sent_at,
28
+ },
29
+ )
30
+
31
+ async def get_by_id(self, notification_id: UUID) -> Notification | None:
32
+ from notifyfork.core.infrastructure.models.notification_model import NotificationModel
33
+ try:
34
+ obj = await NotificationModel.objects.aget(id=notification_id)
35
+ return self._to_entity(obj)
36
+ except NotificationModel.DoesNotExist:
37
+ return None
38
+
39
+ async def get_pending_retries(self, limit: int = 100) -> list[Notification]:
40
+ """
41
+ Single bounded query — never loads all records then filters in Python.
42
+ N+1 safe: one query, one trip to the database.
43
+ """
44
+ from notifyfork.core.infrastructure.models.notification_model import NotificationModel
45
+ qs = (
46
+ NotificationModel.objects
47
+ .filter(status=NotificationModel.StatusChoices.RETRYING)
48
+ .order_by("created_at")[:limit]
49
+ )
50
+ return [self._to_entity(obj) async for obj in qs]
51
+
52
+ @staticmethod
53
+ def _to_entity(obj) -> Notification:
54
+ n = Notification(
55
+ recipient=obj.recipient,
56
+ channel=obj.channel,
57
+ notification_type=obj.notification_type,
58
+ template_id=obj.template_id,
59
+ context=obj.context,
60
+ id=obj.id,
61
+ attempts=obj.attempts,
62
+ max_attempts=obj.max_attempts,
63
+ error_detail=obj.error_detail,
64
+ created_at=obj.created_at,
65
+ sent_at=obj.sent_at,
66
+ provider_used=obj.provider_used,
67
+ )
68
+ n.status = NotificationStatus(obj.status)
69
+ return n
@@ -0,0 +1,23 @@
1
+ import logging
2
+ from notifyfork.core.application.interfaces.template_repository import TemplateRepository
3
+ from notifyfork.core.domain.value_objects.template import NotificationTemplate, TemplateMode, VariableMapping
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class DatabaseTemplateRepository(TemplateRepository):
9
+
10
+ async def get_by_id(self, template_id: str) -> NotificationTemplate | None:
11
+ from notifyfork.core.infrastructure.models.notification_model import NotificationTemplateModel
12
+ try:
13
+ obj = await NotificationTemplateModel.objects.aget(id=template_id, is_active=True)
14
+ return NotificationTemplate(
15
+ id=obj.id,
16
+ body=obj.body,
17
+ subject=obj.subject,
18
+ mode=TemplateMode(obj.mode),
19
+ variable_mapping=VariableMapping(obj.variable_mapping or {}),
20
+ )
21
+ except NotificationTemplateModel.DoesNotExist:
22
+ logger.warning("Template not found", extra={"template_id": template_id})
23
+ return None
File without changes
File without changes
@@ -0,0 +1,21 @@
1
+ class NotificationGatewayError(Exception):
2
+ """Base exception for the notification gateway domain."""
3
+
4
+
5
+ class NoProviderAvailable(NotificationGatewayError):
6
+ def __init__(self, channel: str) -> None:
7
+ super().__init__(f"No provider registered for channel: {channel}")
8
+ self.channel = channel
9
+
10
+
11
+ class TemplateNotFound(NotificationGatewayError):
12
+ def __init__(self, template_id: str) -> None:
13
+ super().__init__(f"Template not found: {template_id}")
14
+ self.template_id = template_id
15
+
16
+
17
+ class ProviderDeliveryError(NotificationGatewayError):
18
+ def __init__(self, provider: str, reason: str) -> None:
19
+ super().__init__(f"Provider '{provider}' failed: {reason}")
20
+ self.provider = provider
21
+ self.reason = reason
File without changes
@@ -0,0 +1,38 @@
1
+ import logging
2
+ import sys
3
+ from typing import Any
4
+ import json
5
+
6
+
7
+ class JSONFormatter(logging.Formatter):
8
+ """
9
+ Structured JSON logs — plays nicely with GCP Cloud Logging,
10
+ Datadog, and any log aggregator that parses JSON.
11
+ """
12
+
13
+ def format(self, record: logging.LogRecord) -> str:
14
+ log: dict[str, Any] = {
15
+ "severity": record.levelname,
16
+ "message": record.getMessage(),
17
+ "logger": record.name,
18
+ "timestamp": self.formatTime(record),
19
+ }
20
+
21
+ if hasattr(record, "__dict__"):
22
+ extras = {
23
+ k: v
24
+ for k, v in record.__dict__.items()
25
+ if k not in logging.LogRecord.__dict__ and not k.startswith("_")
26
+ }
27
+ log.update(extras)
28
+
29
+ if record.exc_info:
30
+ log["exception"] = self.formatException(record.exc_info)
31
+
32
+ return json.dumps(log)
33
+
34
+
35
+ def setup_logging(level: str = "INFO") -> None:
36
+ handler = logging.StreamHandler(sys.stdout)
37
+ handler.setFormatter(JSONFormatter())
38
+ logging.basicConfig(level=level, handlers=[handler])
File without changes