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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from notifyfork.core.application.dtos.send_notification_dto import SendNotificationDTO
|
|
5
|
+
from notifyfork.core.application.interfaces.notification_provider import NotificationProvider
|
|
6
|
+
from notifyfork.core.application.interfaces.notification_repository import NotificationRepository
|
|
7
|
+
from notifyfork.core.application.interfaces.template_repository import TemplateRepository
|
|
8
|
+
from notifyfork.core.domain.entities.notification import Notification
|
|
9
|
+
from notifyfork.shared.exceptions.provider_exceptions import NoProviderAvailable, TemplateNotFound
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SendNotificationUseCase:
|
|
15
|
+
"""
|
|
16
|
+
Orchestrates notification dispatch.
|
|
17
|
+
|
|
18
|
+
Does not know whether templates are local or external —
|
|
19
|
+
that's the provider's responsibility.
|
|
20
|
+
Does not know how Twilio, SendGrid or Firebase work.
|
|
21
|
+
Knows only: get template, pick providers, persist state, deliver.
|
|
22
|
+
|
|
23
|
+
When more than one provider supports the channel, tries them in order
|
|
24
|
+
(see Container's provider ordering) and falls through to the next on
|
|
25
|
+
failure. notification.provider_used always records which one actually
|
|
26
|
+
sent it, and each fallback is logged as it happens.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
repository: NotificationRepository,
|
|
32
|
+
template_repository: TemplateRepository,
|
|
33
|
+
providers: list[NotificationProvider],
|
|
34
|
+
) -> None:
|
|
35
|
+
self._repository = repository
|
|
36
|
+
self._template_repository = template_repository
|
|
37
|
+
self._providers = providers
|
|
38
|
+
|
|
39
|
+
async def execute(self, dto: SendNotificationDTO) -> UUID:
|
|
40
|
+
template = await self._template_repository.get_by_id(dto.template_id)
|
|
41
|
+
if not template:
|
|
42
|
+
raise TemplateNotFound(dto.template_id)
|
|
43
|
+
|
|
44
|
+
candidates = self._resolve_providers(dto.channel)
|
|
45
|
+
|
|
46
|
+
notification = Notification(
|
|
47
|
+
recipient=dto.recipient,
|
|
48
|
+
channel=dto.channel,
|
|
49
|
+
notification_type=dto.notification_type,
|
|
50
|
+
template_id=dto.template_id,
|
|
51
|
+
context=dto.context,
|
|
52
|
+
max_attempts=dto.max_attempts,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
notification.mark_queued()
|
|
56
|
+
await self._repository.save(notification)
|
|
57
|
+
|
|
58
|
+
error = "Unknown provider error"
|
|
59
|
+
for index, provider in enumerate(candidates):
|
|
60
|
+
# Provider handles LOCAL vs EXTERNAL rendering internally
|
|
61
|
+
result = await provider.send_with_template(
|
|
62
|
+
recipient=dto.recipient,
|
|
63
|
+
template=template,
|
|
64
|
+
context=dto.context,
|
|
65
|
+
)
|
|
66
|
+
if result.success:
|
|
67
|
+
notification.mark_sent(provider.name)
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
error = result.error or "Unknown provider error"
|
|
71
|
+
has_fallback = index < len(candidates) - 1
|
|
72
|
+
logger.warning(
|
|
73
|
+
"Provider failed" + (", falling back to next provider" if has_fallback else ""),
|
|
74
|
+
extra={
|
|
75
|
+
"provider": provider.name,
|
|
76
|
+
"channel": dto.channel,
|
|
77
|
+
"error": error,
|
|
78
|
+
"fallback_available": has_fallback,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
notification.mark_failed(error)
|
|
83
|
+
|
|
84
|
+
await self._repository.save(notification)
|
|
85
|
+
return notification.id
|
|
86
|
+
|
|
87
|
+
def _resolve_providers(self, channel) -> list[NotificationProvider]:
|
|
88
|
+
candidates = [p for p in self._providers if p.supports(channel)]
|
|
89
|
+
if not candidates:
|
|
90
|
+
raise NoProviderAvailable(channel)
|
|
91
|
+
return candidates
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import UUID, uuid4
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NotificationStatus(str, Enum):
|
|
12
|
+
PENDING = "pending"
|
|
13
|
+
QUEUED = "queued"
|
|
14
|
+
SENT = "sent" # provider accepted — not yet confirmed delivered
|
|
15
|
+
DELIVERED = "delivered" # provider confirmed delivery to recipient
|
|
16
|
+
DELIVERY_FAILED = "delivery_failed" # provider confirmed it could NOT deliver
|
|
17
|
+
FAILED = "failed" # our system exhausted all retry attempts
|
|
18
|
+
RETRYING = "retrying"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotificationChannel(str, Enum):
|
|
22
|
+
SMS = "sms"
|
|
23
|
+
EMAIL = "email"
|
|
24
|
+
PUSH = "push"
|
|
25
|
+
WHATSAPP = "whatsapp"
|
|
26
|
+
SLACK = "slack"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NotificationType(str, Enum):
|
|
30
|
+
TRANSACTIONAL = "transactional"
|
|
31
|
+
ALERT = "alert"
|
|
32
|
+
MARKETING = "marketing"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Notification:
|
|
37
|
+
"""
|
|
38
|
+
Core domain entity. Owns its own state machine.
|
|
39
|
+
|
|
40
|
+
State flow:
|
|
41
|
+
PENDING → QUEUED → SENT ──────────────→ DELIVERED (confirmed by provider webhook)
|
|
42
|
+
↘ RETRYING → ↗ (retry succeeded)
|
|
43
|
+
→ DELIVERY_FAILED (provider confirmed failure)
|
|
44
|
+
→ FAILED (our retries exhausted)
|
|
45
|
+
|
|
46
|
+
SENT means "provider accepted" — not "recipient got it".
|
|
47
|
+
DELIVERED means the provider webhook confirmed actual delivery.
|
|
48
|
+
DELIVERY_FAILED means the provider confirmed it could not deliver
|
|
49
|
+
(bad number, full inbox, uninstalled app, etc).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
recipient: str
|
|
53
|
+
channel: str
|
|
54
|
+
notification_type: str
|
|
55
|
+
template_id: str
|
|
56
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
id: UUID = field(default_factory=uuid4)
|
|
58
|
+
status: NotificationStatus = field(default=NotificationStatus.PENDING)
|
|
59
|
+
provider_used: str | None = None
|
|
60
|
+
provider_message_id: str | None = None # external ID for webhook correlation
|
|
61
|
+
attempts: int = 0
|
|
62
|
+
max_attempts: int = 3
|
|
63
|
+
error_detail: str | None = None
|
|
64
|
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
|
65
|
+
sent_at: datetime | None = None
|
|
66
|
+
delivered_at: datetime | None = None
|
|
67
|
+
|
|
68
|
+
def mark_queued(self) -> None:
|
|
69
|
+
logger.info("Notification queued", extra={
|
|
70
|
+
"notification_id": str(self.id), "channel": self.channel,
|
|
71
|
+
})
|
|
72
|
+
self.status = NotificationStatus.QUEUED
|
|
73
|
+
|
|
74
|
+
def mark_sent(self, provider: str, provider_message_id: str | None = None) -> None:
|
|
75
|
+
self.status = NotificationStatus.SENT
|
|
76
|
+
self.provider_used = provider
|
|
77
|
+
self.provider_message_id = provider_message_id
|
|
78
|
+
self.sent_at = datetime.utcnow()
|
|
79
|
+
logger.info("Notification sent — awaiting delivery confirmation", extra={
|
|
80
|
+
"notification_id": str(self.id),
|
|
81
|
+
"provider": provider,
|
|
82
|
+
"provider_message_id": provider_message_id,
|
|
83
|
+
"channel": self.channel,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
def mark_delivered(self) -> None:
|
|
87
|
+
"""Called when provider webhook confirms delivery to recipient."""
|
|
88
|
+
if self.status != NotificationStatus.SENT:
|
|
89
|
+
logger.warning("mark_delivered called on non-SENT notification", extra={
|
|
90
|
+
"notification_id": str(self.id), "current_status": self.status,
|
|
91
|
+
})
|
|
92
|
+
self.status = NotificationStatus.DELIVERED
|
|
93
|
+
self.delivered_at = datetime.utcnow()
|
|
94
|
+
logger.info("Notification delivered", extra={
|
|
95
|
+
"notification_id": str(self.id),
|
|
96
|
+
"provider": self.provider_used,
|
|
97
|
+
"latency_ms": int((self.delivered_at - self.sent_at).total_seconds() * 1000)
|
|
98
|
+
if self.sent_at else None,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
def mark_delivery_failed(self, reason: str) -> None:
|
|
102
|
+
"""Called when provider webhook confirms it could NOT deliver."""
|
|
103
|
+
self.status = NotificationStatus.DELIVERY_FAILED
|
|
104
|
+
self.error_detail = reason
|
|
105
|
+
logger.error("Delivery failed (provider confirmed)", extra={
|
|
106
|
+
"notification_id": str(self.id),
|
|
107
|
+
"provider": self.provider_used,
|
|
108
|
+
"reason": reason,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
def mark_failed(self, reason: str) -> None:
|
|
112
|
+
"""Called when our retry logic gives up."""
|
|
113
|
+
self.attempts += 1
|
|
114
|
+
self.error_detail = reason
|
|
115
|
+
if self.attempts >= self.max_attempts:
|
|
116
|
+
self.status = NotificationStatus.FAILED
|
|
117
|
+
logger.error("Notification permanently failed", extra={
|
|
118
|
+
"notification_id": str(self.id),
|
|
119
|
+
"attempts": self.attempts,
|
|
120
|
+
"reason": reason,
|
|
121
|
+
})
|
|
122
|
+
else:
|
|
123
|
+
self.status = NotificationStatus.RETRYING
|
|
124
|
+
logger.warning("Notification failed, will retry", extra={
|
|
125
|
+
"notification_id": str(self.id),
|
|
126
|
+
"attempt": self.attempts,
|
|
127
|
+
"max_attempts": self.max_attempts,
|
|
128
|
+
"reason": reason,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def can_retry(self) -> bool:
|
|
133
|
+
return self.attempts < self.max_attempts
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def is_terminal(self) -> bool:
|
|
137
|
+
return self.status in (
|
|
138
|
+
NotificationStatus.DELIVERED,
|
|
139
|
+
NotificationStatus.DELIVERY_FAILED,
|
|
140
|
+
NotificationStatus.FAILED,
|
|
141
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class NotificationQueued:
|
|
8
|
+
notification_id: UUID
|
|
9
|
+
channel: str
|
|
10
|
+
recipient: str
|
|
11
|
+
occurred_at: datetime = field(default_factory=datetime.utcnow)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class NotificationSent:
|
|
16
|
+
notification_id: UUID
|
|
17
|
+
provider: str
|
|
18
|
+
occurred_at: datetime = field(default_factory=datetime.utcnow)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class NotificationFailed:
|
|
23
|
+
notification_id: UUID
|
|
24
|
+
reason: str
|
|
25
|
+
attempts: int
|
|
26
|
+
occurred_at: datetime = field(default_factory=datetime.utcnow)
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from string import Template
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TemplateMode(str, Enum):
|
|
8
|
+
LOCAL = "local" # rendered here — body is the content
|
|
9
|
+
EXTERNAL = "external" # rendered by provider — body is the template ID
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class VariableMapping:
|
|
14
|
+
"""
|
|
15
|
+
Maps our semantic variable names to provider-specific keys.
|
|
16
|
+
|
|
17
|
+
Twilio WhatsApp uses positional keys: {"name": "1", "code": "2"}
|
|
18
|
+
SendGrid uses named keys: {"name": "user_name", "code": "otp_code"}
|
|
19
|
+
Mailgun uses the same name: {"name": "name", "code": "code"}
|
|
20
|
+
|
|
21
|
+
This keeps your context payload consistent regardless of provider.
|
|
22
|
+
You always send {"name": "Mario", "code": "123"} and the mapping
|
|
23
|
+
translates to whatever the provider expects.
|
|
24
|
+
"""
|
|
25
|
+
mappings: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
def translate(self, context: dict[str, Any]) -> dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Translates context keys using the mapping.
|
|
30
|
+
Keys not in the mapping are passed through as-is.
|
|
31
|
+
"""
|
|
32
|
+
if not self.mappings:
|
|
33
|
+
return context
|
|
34
|
+
return {
|
|
35
|
+
self.mappings.get(k, k): v
|
|
36
|
+
for k, v in context.items()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class NotificationTemplate:
|
|
42
|
+
"""
|
|
43
|
+
Unified template value object supporting local and external rendering.
|
|
44
|
+
|
|
45
|
+
LOCAL mode:
|
|
46
|
+
body is the actual content with $variable placeholders.
|
|
47
|
+
Rendered here using Python's string.Template.
|
|
48
|
+
|
|
49
|
+
EXTERNAL mode:
|
|
50
|
+
body is the external template ID (e.g. Twilio SID "HXabc123",
|
|
51
|
+
SendGrid ID "d-abc123", Mailgun template name "otp-email").
|
|
52
|
+
variable_mapping translates our context to provider-expected keys.
|
|
53
|
+
Provider receives the ID + translated variables — renders on their end.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
id: str
|
|
57
|
+
body: str # content OR external template ID
|
|
58
|
+
mode: TemplateMode = TemplateMode.LOCAL
|
|
59
|
+
subject: str | None = None # email subject (LOCAL mode)
|
|
60
|
+
variable_mapping: VariableMapping = field(default_factory=VariableMapping)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_external(self) -> bool:
|
|
64
|
+
return self.mode == TemplateMode.EXTERNAL
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def external_template_id(self) -> str:
|
|
68
|
+
"""Alias for clarity when in EXTERNAL mode."""
|
|
69
|
+
if not self.is_external:
|
|
70
|
+
raise ValueError("external_template_id is only valid in EXTERNAL mode")
|
|
71
|
+
return self.body
|
|
72
|
+
|
|
73
|
+
def render(self, context: dict[str, Any]) -> str:
|
|
74
|
+
"""Render body locally. Only valid in LOCAL mode."""
|
|
75
|
+
if self.is_external:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
"Cannot render locally — this template uses EXTERNAL mode. "
|
|
78
|
+
"Pass the template to the provider directly."
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
return Template(self.body).substitute(context)
|
|
82
|
+
except KeyError as e:
|
|
83
|
+
raise ValueError(f"Missing template variable: {e}") from e
|
|
84
|
+
|
|
85
|
+
def render_subject(self, context: dict[str, Any]) -> str | None:
|
|
86
|
+
if not self.subject:
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
return Template(self.subject).substitute(context)
|
|
90
|
+
except KeyError as e:
|
|
91
|
+
raise ValueError(f"Missing subject variable: {e}") from e
|
|
92
|
+
|
|
93
|
+
def translate_variables(self, context: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
"""Translate context to provider-expected variable names."""
|
|
95
|
+
return self.variable_mapping.translate(context)
|
|
File without changes
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency container for NotifyFork.
|
|
3
|
+
|
|
4
|
+
Reads environment variables and wires everything together.
|
|
5
|
+
One place to look when adding a new provider or changing config.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from notifyfork.core.infrastructure.container.providers import Container
|
|
9
|
+
|
|
10
|
+
use_case = Container.send_notification_use_case()
|
|
11
|
+
await use_case.execute(dto)
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import logging
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from notifyfork.core.application.interfaces.notification_provider import NotificationProvider
|
|
19
|
+
from notifyfork.core.application.use_cases.send_notification import SendNotificationUseCase
|
|
20
|
+
from notifyfork.core.infrastructure.repositories.notification_repository import DjangoNotificationRepository
|
|
21
|
+
from notifyfork.core.infrastructure.repositories.template_repository import DatabaseTemplateRepository
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
DEFAULT_PROVIDER_ORDER = [
|
|
26
|
+
"twilio_sms",
|
|
27
|
+
"twilio_whatsapp",
|
|
28
|
+
"sendgrid_email",
|
|
29
|
+
"resend_email",
|
|
30
|
+
"smtp_email",
|
|
31
|
+
"firebase_push",
|
|
32
|
+
"slack",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Providers registered from outside the lib via @notifyfork.provider.
|
|
36
|
+
# Duck-typed on purpose — no NotificationProvider subclassing required,
|
|
37
|
+
# same as every built-in provider is only ever used through .name /
|
|
38
|
+
# .send_with_template() / .supported_channels, never isinstance-checked.
|
|
39
|
+
_extra_providers: list[Any] = []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ordered(providers: list[NotificationProvider]) -> list[NotificationProvider]:
|
|
43
|
+
"""
|
|
44
|
+
Orders providers for fallback. When more than one provider supports the
|
|
45
|
+
same channel (e.g. SendGrid + SMTP for email), SendNotificationUseCase
|
|
46
|
+
tries them in this order — first one that succeeds wins.
|
|
47
|
+
|
|
48
|
+
Override with NOTIFYFORK_PROVIDER_ORDER="sendgrid_email,smtp_email"
|
|
49
|
+
(comma-separated provider names). Anything registered but not listed
|
|
50
|
+
keeps its default position, appended after the ones you did list.
|
|
51
|
+
"""
|
|
52
|
+
raw = os.environ.get("NOTIFYFORK_PROVIDER_ORDER")
|
|
53
|
+
order = [name.strip() for name in raw.split(",")] if raw else DEFAULT_PROVIDER_ORDER
|
|
54
|
+
|
|
55
|
+
by_name = {p.name: p for p in providers}
|
|
56
|
+
ordered = [by_name[name] for name in order if name in by_name]
|
|
57
|
+
remaining = [p for p in providers if p.name not in order]
|
|
58
|
+
return ordered + remaining
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _build_providers() -> list[NotificationProvider]:
|
|
62
|
+
"""
|
|
63
|
+
Instantiates only the providers that have credentials configured.
|
|
64
|
+
Missing credentials → provider is skipped with a warning.
|
|
65
|
+
This lets you run locally with only the providers you need.
|
|
66
|
+
"""
|
|
67
|
+
providers = []
|
|
68
|
+
|
|
69
|
+
# Twilio SMS
|
|
70
|
+
twilio_sid = os.environ.get("TWILIO_ACCOUNT_SID")
|
|
71
|
+
twilio_token = os.environ.get("TWILIO_AUTH_TOKEN")
|
|
72
|
+
twilio_from = os.environ.get("TWILIO_FROM_NUMBER")
|
|
73
|
+
|
|
74
|
+
if all([twilio_sid, twilio_token, twilio_from]):
|
|
75
|
+
from notifyfork.core.infrastructure.providers.twilio_provider import TwilioSMSProvider
|
|
76
|
+
providers.append(TwilioSMSProvider(twilio_sid, twilio_token, twilio_from))
|
|
77
|
+
logger.info("Provider registered: twilio_sms")
|
|
78
|
+
else:
|
|
79
|
+
logger.warning("twilio_sms skipped — missing TWILIO_ACCOUNT_SID / AUTH_TOKEN / FROM_NUMBER")
|
|
80
|
+
|
|
81
|
+
# Twilio WhatsApp
|
|
82
|
+
twilio_wa_from = os.environ.get("TWILIO_WHATSAPP_FROM_NUMBER")
|
|
83
|
+
|
|
84
|
+
if all([twilio_sid, twilio_token, twilio_wa_from]):
|
|
85
|
+
from notifyfork.core.infrastructure.providers.whatsapp_provider import TwilioWhatsAppProvider
|
|
86
|
+
providers.append(TwilioWhatsAppProvider(twilio_sid, twilio_token, twilio_wa_from))
|
|
87
|
+
logger.info("Provider registered: twilio_whatsapp")
|
|
88
|
+
else:
|
|
89
|
+
logger.warning("twilio_whatsapp skipped — missing TWILIO_WHATSAPP_FROM_NUMBER")
|
|
90
|
+
|
|
91
|
+
# SendGrid Email
|
|
92
|
+
sendgrid_key = os.environ.get("SENDGRID_API_KEY")
|
|
93
|
+
sendgrid_from = os.environ.get("SENDGRID_FROM_EMAIL")
|
|
94
|
+
sendgrid_name = os.environ.get("SENDGRID_FROM_NAME", "NotifyFork")
|
|
95
|
+
|
|
96
|
+
if all([sendgrid_key, sendgrid_from]):
|
|
97
|
+
from notifyfork.core.infrastructure.providers.sendgrid_provider import SendGridEmailProvider
|
|
98
|
+
providers.append(SendGridEmailProvider(sendgrid_key, sendgrid_from, sendgrid_name))
|
|
99
|
+
logger.info("Provider registered: sendgrid_email")
|
|
100
|
+
else:
|
|
101
|
+
logger.warning("sendgrid_email skipped — missing SENDGRID_API_KEY / FROM_EMAIL")
|
|
102
|
+
|
|
103
|
+
# Resend Email
|
|
104
|
+
resend_key = os.environ.get("RESEND_API_KEY")
|
|
105
|
+
resend_from = os.environ.get("RESEND_FROM_EMAIL")
|
|
106
|
+
resend_name = os.environ.get("RESEND_FROM_NAME", "")
|
|
107
|
+
|
|
108
|
+
if all([resend_key, resend_from]):
|
|
109
|
+
from notifyfork.core.infrastructure.providers.resend_provider import ResendEmailProvider
|
|
110
|
+
providers.append(ResendEmailProvider(resend_key, resend_from, resend_name))
|
|
111
|
+
logger.info("Provider registered: resend_email")
|
|
112
|
+
else:
|
|
113
|
+
logger.warning("resend_email skipped — missing RESEND_API_KEY / FROM_EMAIL")
|
|
114
|
+
|
|
115
|
+
# SMTP Email (fallback)
|
|
116
|
+
smtp_host = os.environ.get("SMTP_HOST")
|
|
117
|
+
smtp_user = os.environ.get("SMTP_USERNAME")
|
|
118
|
+
smtp_pass = os.environ.get("SMTP_PASSWORD")
|
|
119
|
+
smtp_from = os.environ.get("SMTP_FROM_EMAIL")
|
|
120
|
+
|
|
121
|
+
if all([smtp_host, smtp_user, smtp_pass, smtp_from]):
|
|
122
|
+
from notifyfork.core.infrastructure.providers.smtp_provider import SMTPEmailProvider
|
|
123
|
+
providers.append(SMTPEmailProvider(
|
|
124
|
+
host=smtp_host,
|
|
125
|
+
port=int(os.environ.get("SMTP_PORT", "465")),
|
|
126
|
+
username=smtp_user,
|
|
127
|
+
password=smtp_pass,
|
|
128
|
+
from_email=smtp_from,
|
|
129
|
+
))
|
|
130
|
+
logger.info("Provider registered: smtp_email")
|
|
131
|
+
else:
|
|
132
|
+
logger.warning("smtp_email skipped — missing SMTP_HOST / USERNAME / PASSWORD / FROM_EMAIL")
|
|
133
|
+
|
|
134
|
+
# Firebase Push
|
|
135
|
+
firebase_creds = os.environ.get("FIREBASE_CREDENTIALS_PATH")
|
|
136
|
+
|
|
137
|
+
if firebase_creds:
|
|
138
|
+
from notifyfork.core.infrastructure.providers.firebase_provider import FirebasePushProvider
|
|
139
|
+
providers.append(FirebasePushProvider())
|
|
140
|
+
logger.info("Provider registered: firebase_push")
|
|
141
|
+
else:
|
|
142
|
+
logger.warning("firebase_push skipped — missing FIREBASE_CREDENTIALS_PATH")
|
|
143
|
+
|
|
144
|
+
# Slack
|
|
145
|
+
slack_token = os.environ.get("SLACK_BOT_TOKEN")
|
|
146
|
+
|
|
147
|
+
if slack_token:
|
|
148
|
+
from notifyfork.core.infrastructure.providers.slack_provider import SlackProvider
|
|
149
|
+
providers.append(SlackProvider(bot_token=slack_token))
|
|
150
|
+
logger.info("Provider registered: slack")
|
|
151
|
+
else:
|
|
152
|
+
logger.warning("slack skipped — missing SLACK_BOT_TOKEN")
|
|
153
|
+
|
|
154
|
+
providers.extend(_extra_providers)
|
|
155
|
+
|
|
156
|
+
if not providers:
|
|
157
|
+
logger.error("No providers registered — check your environment variables")
|
|
158
|
+
|
|
159
|
+
return _ordered(providers)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Container:
|
|
163
|
+
"""
|
|
164
|
+
Simple service locator. Not a full DI framework on purpose —
|
|
165
|
+
keeps the dependency graph explicit and easy to follow.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
@lru_cache(maxsize=1)
|
|
170
|
+
def providers() -> list[NotificationProvider]:
|
|
171
|
+
return _build_providers()
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def notification_repository() -> DjangoNotificationRepository:
|
|
175
|
+
return DjangoNotificationRepository()
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def template_repository() -> DatabaseTemplateRepository:
|
|
179
|
+
return DatabaseTemplateRepository()
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def send_notification_use_case() -> SendNotificationUseCase:
|
|
183
|
+
return SendNotificationUseCase(
|
|
184
|
+
repository=Container.notification_repository(),
|
|
185
|
+
template_repository=Container.template_repository(),
|
|
186
|
+
providers=Container.providers(),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def provider(provider_cls: type) -> type:
|
|
191
|
+
"""
|
|
192
|
+
Decorator that registers a custom provider from outside the lib.
|
|
193
|
+
|
|
194
|
+
No need to subclass NotificationProvider — duck-typing, same as every
|
|
195
|
+
built-in provider (the container only ever reads .name and calls
|
|
196
|
+
.send_with_template()). Just instantiate-and-go:
|
|
197
|
+
|
|
198
|
+
import notifyfork
|
|
199
|
+
|
|
200
|
+
@notifyfork.provider
|
|
201
|
+
class XptoProvider:
|
|
202
|
+
name = "xpto"
|
|
203
|
+
def send_with_template(self, recipient, template, context): ...
|
|
204
|
+
|
|
205
|
+
The class is instantiated with no arguments, so anything it needs
|
|
206
|
+
(API keys, clients, etc.) should be read in __init__ from the
|
|
207
|
+
environment, same as the built-in providers do in _build_providers().
|
|
208
|
+
"""
|
|
209
|
+
instance = provider_cls()
|
|
210
|
+
_extra_providers.append(instance)
|
|
211
|
+
|
|
212
|
+
# Container.providers() is lru_cache'd — if something already read it
|
|
213
|
+
# before this decorator ran, drop the stale result so the new provider
|
|
214
|
+
# is picked up on the next call.
|
|
215
|
+
Container.providers.cache_clear()
|
|
216
|
+
|
|
217
|
+
logger.info("Provider registered: %s", getattr(instance, "name", provider_cls.__name__))
|
|
218
|
+
return provider_cls
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
|
|
6
|
+
initial = True
|
|
7
|
+
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.CreateModel(
|
|
12
|
+
name="NotificationModel",
|
|
13
|
+
fields=[
|
|
14
|
+
("id", models.UUIDField(primary_key=True, editable=False)),
|
|
15
|
+
("recipient", models.CharField(max_length=255)),
|
|
16
|
+
("channel", models.CharField(max_length=20)),
|
|
17
|
+
("notification_type", models.CharField(max_length=20)),
|
|
18
|
+
("template_id", models.CharField(max_length=100)),
|
|
19
|
+
("context", models.JSONField(default=dict)),
|
|
20
|
+
("status", models.CharField(max_length=20, default="pending", db_index=True)),
|
|
21
|
+
("provider_used", models.CharField(max_length=50, null=True, blank=True)),
|
|
22
|
+
("attempts", models.PositiveSmallIntegerField(default=0)),
|
|
23
|
+
("max_attempts", models.PositiveSmallIntegerField(default=3)),
|
|
24
|
+
("error_detail", models.TextField(null=True, blank=True)),
|
|
25
|
+
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
26
|
+
("sent_at", models.DateTimeField(null=True, blank=True)),
|
|
27
|
+
],
|
|
28
|
+
options={"db_table": "notifyfork_notifications", "ordering": ["-created_at"]},
|
|
29
|
+
),
|
|
30
|
+
migrations.AddIndex(
|
|
31
|
+
model_name="NotificationModel",
|
|
32
|
+
index=models.Index(fields=["status", "attempts"], name="idx_status_attempts"),
|
|
33
|
+
),
|
|
34
|
+
migrations.CreateModel(
|
|
35
|
+
name="NotificationTemplateModel",
|
|
36
|
+
fields=[
|
|
37
|
+
("id", models.CharField(max_length=100, primary_key=True)),
|
|
38
|
+
("body", models.TextField()),
|
|
39
|
+
("subject", models.CharField(max_length=255, null=True, blank=True)),
|
|
40
|
+
("mode", models.CharField(max_length=10, default="local")),
|
|
41
|
+
("variable_mapping", models.JSONField(default=dict)),
|
|
42
|
+
("is_active", models.BooleanField(default=True)),
|
|
43
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
44
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
45
|
+
],
|
|
46
|
+
options={"db_table": "notifyfork_templates"},
|
|
47
|
+
),
|
|
48
|
+
]
|