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,599 @@
1
+ Metadata-Version: 2.4
2
+ Name: notifyfork
3
+ Version: 0.1.2
4
+ Summary: Provider-agnostic notification gateway for Django. Send to SMS, Email, WhatsApp, Push, and Slack via a single API.
5
+ Project-URL: Homepage, https://github.com/marioasaraujo/notifyfork
6
+ Project-URL: Documentation, https://github.com/marioasaraujo/notifyfork#readme
7
+ Project-URL: Repository, https://github.com/marioasaraujo/notifyfork
8
+ Project-URL: Bug Tracker, https://github.com/marioasaraujo/notifyfork/issues
9
+ Author-email: Mario Araujo <marioasaraujo@gmail.com>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2024 Mario Araujo
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: celery,django,email,firebase,notifications,resend,sendgrid,slack,sms,twilio,whatsapp
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Framework :: Django
35
+ Classifier: Framework :: Django :: 5.0
36
+ Classifier: Intended Audience :: Developers
37
+ Classifier: License :: OSI Approved :: MIT License
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Topic :: Communications
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Requires-Python: >=3.12
43
+ Requires-Dist: celery[redis]>=5.4
44
+ Requires-Dist: django>=5.0
45
+ Requires-Dist: djangorestframework>=3.15
46
+ Requires-Dist: firebase-admin>=6.5
47
+ Requires-Dist: httpx>=0.27
48
+ Requires-Dist: pydantic>=2.7
49
+ Requires-Dist: redis>=5.0
50
+ Requires-Dist: twilio>=9.0
51
+ Provides-Extra: dev
52
+ Requires-Dist: hatch>=1.9; extra == 'dev'
53
+ Requires-Dist: mypy>=1.10; extra == 'dev'
54
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
55
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
56
+ Requires-Dist: pytest-django>=4.8; extra == 'dev'
57
+ Requires-Dist: pytest>=8.2; extra == 'dev'
58
+ Requires-Dist: ruff>=0.4; extra == 'dev'
59
+ Description-Content-Type: text/markdown
60
+
61
+ <div align="center">
62
+
63
+ <img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=0:0f2027,50:203a43,100:2c5364&height=100&section=header&animation=fadeIn"/>
64
+
65
+ <h1>📡 NotifyFork</h1>
66
+
67
+ <p><strong>Provider-agnostic notification gateway for Django.</strong><br/>
68
+ One API. Any channel. Send to SMS, Email, WhatsApp, Push, and Slack,<br/>
69
+ delivered asynchronously, retried safely, logged in structured JSON.</p>
70
+
71
+ [![PyPI version](https://img.shields.io/pypi/v/notifyfork?style=flat-square&color=2c5364)](https://pypi.org/project/notifyfork)
72
+ [![Python](https://img.shields.io/badge/Python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](https://python.org)
73
+ [![Django](https://img.shields.io/badge/Django-5.x-092E20?style=flat-square&logo=django)](https://djangoproject.com)
74
+ [![Celery](https://img.shields.io/badge/Celery-5.x-37814A?style=flat-square&logo=celery)](https://docs.celeryq.dev)
75
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](LICENSE)
76
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](CONTRIBUTING.md)
77
+
78
+ **[English](#english) · [Português](#português)**
79
+
80
+ </div>
81
+
82
+ ---
83
+
84
+ <a name="english"></a>
85
+ ## 🇬🇧 English
86
+
87
+ ### What is NotifyFork?
88
+
89
+ Direct Twilio calls scattered through a codebase get messy fast: duplicated retry logic, then someone needs WhatsApp and it all falls apart.
90
+
91
+ NotifyFork is a thin, provider-agnostic delivery layer. You already know the channel and template you want; it picks the right provider, enqueues the delivery, retries on failure, and logs everything as structured JSON.
92
+
93
+ ```bash
94
+ pip install notifyfork
95
+ ```
96
+
97
+ ```python
98
+ import notifyfork
99
+
100
+ notifyfork.send(
101
+ recipient="+5511999999999",
102
+ channel="sms",
103
+ template_id="otp_sms",
104
+ notification_type="transactional",
105
+ context={"code": "847291"},
106
+ )
107
+ # → enqueued to Celery, retried on failure
108
+ ```
109
+
110
+ That's the whole API. Provider selection, template rendering, and retry all happen behind the queue.
111
+
112
+ ---
113
+
114
+ ### Channels & providers
115
+
116
+ | Channel | Provider | Template mode |
117
+ |---------|----------|---------------|
118
+ | SMS | Twilio | Local (free-form text) |
119
+ | Email | SendGrid | Local **or** External (Dynamic Templates) |
120
+ | Email | Resend | Local (HTML rendered here) |
121
+ | Email | SMTP (any server) | Local (HTML rendered here) |
122
+ | WhatsApp | Twilio | Local (sandbox) **or** External (Content Templates) |
123
+ | Push | Firebase Cloud Messaging | Local (title + body) |
124
+ | Slack | Slack Web API | Local (plain or Block Kit) |
125
+
126
+ Multiple providers per channel fall back on each other automatically (see "Reliability"). Adding a provider is one new class — see "Adding a provider" below. `channel` isn't limited to this table either; you can register a provider for a channel NotifyFork doesn't ship at all.
127
+
128
+ **Generic vs. explicit `channel`**: every built-in provider accepts `channel` two ways — the generic form (`"whatsapp"`, `"email"`, `"sms"`) and its own `vendor_channel` name (`twilio_whatsapp`, `sendgrid_email`, `twilio_sms`...), because `supported_channels` lists both:
129
+
130
+ ```python
131
+ notifyfork.send(channel="whatsapp", ...) # generic — eligible for fallback if
132
+ # another WhatsApp provider is registered
133
+ notifyfork.send(channel="twilio_whatsapp", ...) # explicit — pins Twilio, no fallback
134
+ ```
135
+
136
+ Use generic when you want NotifyFork to pick/fall back between whichever providers are registered for that channel (ordered by `DEFAULT_PROVIDER_ORDER` / `NOTIFYFORK_PROVIDER_ORDER`, recorded in `notification.provider_used`). Use the explicit `vendor_channel` form when you specifically need that vendor — e.g. an **EXTERNAL**-mode template holds a vendor-specific template ID (a Twilio Content SID, a SendGrid Dynamic Template ID) that only the issuing provider understands, so pinning the channel documents that lock instead of implying a fallback that couldn't work anyway. `slack` is the one provider without a separate explicit form: there's only one Slack integration (the Web API), and vendor and channel are already the same word, so `slack_slack` would add nothing. See the runnable [examples](examples/) for both forms in context.
137
+
138
+ ---
139
+
140
+ ### Template modes
141
+
142
+ **LOCAL**: body is rendered here using Python's `string.Template`.
143
+
144
+ ```python
145
+ body = "Your code is: $code"
146
+ context = {"code": "847291"}
147
+ # → "Your code is: 847291"
148
+ ```
149
+
150
+ **EXTERNAL**: body is the provider's template ID. Variables translated via `VariableMapping` before dispatch.
151
+
152
+ ```python
153
+ # SendGrid Dynamic Template
154
+ body = "d-abc123def456"
155
+ variable_mapping = {"name": "customer_name", "total": "order_total"}
156
+
157
+ # Twilio WhatsApp Content Template (positional)
158
+ body = "HXabc123def456"
159
+ variable_mapping = {"name": "1", "code": "2"}
160
+ ```
161
+
162
+ ---
163
+
164
+ ### Architecture
165
+
166
+ ```
167
+ notifyfork.send() (or your own view calling it)
168
+
169
+
170
+ Celery Queue ← async, acks_late, exponential backoff
171
+
172
+
173
+ SendNotificationUseCase
174
+ ├── TemplateRepository ← loads template + variable mapping
175
+ ├── ProviderRegistry ← picks provider by channel
176
+ └── NotificationRepository ← persists state transitions
177
+
178
+ State: PENDING → QUEUED → SENT
179
+ ↘ RETRYING (attempt 1, 2...)
180
+ ↘ FAILED
181
+ ```
182
+
183
+ The domain layer has **zero imports from providers or Django**. Swap PostgreSQL, change Celery to SQS, replace Twilio: the core logic stays untouched.
184
+
185
+ ---
186
+
187
+ ### Getting started
188
+
189
+ ```bash
190
+ pip install notifyfork
191
+ ```
192
+
193
+ ```python
194
+ # settings.py
195
+ INSTALLED_APPS = [
196
+ ...,
197
+ "notifyfork.core.infrastructure",
198
+ ]
199
+ ```
200
+
201
+ ```python
202
+ # urls.py (optional, only needed to receive provider delivery webhooks)
203
+ urlpatterns = [
204
+ ...,
205
+ path("api/v1/", include("notifyfork.api.urls")),
206
+ ]
207
+ ```
208
+
209
+ ```bash
210
+ cp .env.example .env # fill in your provider credentials
211
+ python manage.py migrate
212
+ celery -A yourproject worker --loglevel=info # your own Celery app, autodiscovers NotifyFork's tasks
213
+ ```
214
+
215
+ Then try it with any of the [runnable examples](examples/) via `python manage.py shell`.
216
+
217
+ ---
218
+
219
+ ### Sending a notification
220
+
221
+ **Same project**: call `notifyfork.send(...)` directly in Python, as shown above. No HTTP needed.
222
+
223
+ **Different service** (another microservice, another language): NotifyFork doesn't ship a public HTTP endpoint on purpose — auth is different per deployment, not something a library should decide for you. Add a thin authenticated view instead:
224
+
225
+ ```python
226
+ # yourproject/notifications/views.py
227
+ from rest_framework.views import APIView
228
+ from rest_framework.response import Response
229
+ from rest_framework.permissions import IsAuthenticated # or whatever auth you use
230
+ import notifyfork
231
+
232
+ class SendNotificationView(APIView):
233
+ permission_classes = [IsAuthenticated]
234
+
235
+ def post(self, request):
236
+ task = notifyfork.send(
237
+ recipient=request.data["recipient"],
238
+ channel=request.data["channel"],
239
+ template_id=request.data["template_id"],
240
+ notification_type=request.data["notification_type"],
241
+ context=request.data.get("context", {}),
242
+ )
243
+ return Response({"task_id": task.id}, status=202)
244
+ ```
245
+
246
+ Other services POST to whatever URL and auth scheme you chose — you control the contract, NotifyFork just does the delivery behind it.
247
+
248
+ The delivery-status webhooks (`notifyfork.api.webhooks`) are the one exception meant to be mounted directly: they validate the provider's own signature (Twilio, SendGrid, Resend), so they don't need your app's auth.
249
+
250
+ ---
251
+
252
+ ### Adding a provider
253
+
254
+ **Built into the lib** — subclass `NotificationProvider` and register it in `container/providers.py`:
255
+
256
+ ```python
257
+ # notifyfork/core/infrastructure/providers/myvendor_provider.py
258
+ class MyVendorSMSProvider(NotificationProvider):
259
+ @property
260
+ def name(self) -> str:
261
+ return "myvendor_sms" # vendor_channel — see "channel vs. provider.name" above
262
+
263
+ @property
264
+ def supported_channels(self) -> list[NotificationChannel]:
265
+ return [NotificationChannel.SMS]
266
+
267
+ async def send_with_template(self, recipient, template, context) -> ProviderResult:
268
+ body = template.render(context)
269
+ # your API call here
270
+ ...
271
+ ```
272
+
273
+ **From your own project** — no subclassing, no editing the container, just decorate a plain class (duck-typed, only `.name` and `.send_with_template()` are ever touched):
274
+
275
+ ```python
276
+ import notifyfork
277
+
278
+ @notifyfork.provider
279
+ class TelegramProvider:
280
+ name = "telegram" # vendor == channel here (like "slack"), no _channel suffix needed
281
+ supported_channels = ["telegram"] # channel isn't a closed enum — any string works
282
+
283
+ def supports(self, channel):
284
+ return channel in self.supported_channels
285
+
286
+ async def send_with_template(self, recipient, template, context):
287
+ ...
288
+
289
+ notifyfork.send(recipient="@someone", channel="telegram", template_id="greeting", notification_type="transactional")
290
+ ```
291
+
292
+ The class is instantiated with no arguments and appended to `Container.providers()`. See [`examples/custom_provider`](examples/custom_provider).
293
+
294
+ ---
295
+
296
+ ### Adding a kind of notification
297
+
298
+ No event catalog to register. Pick a `channel`, write a template via migration, call `notifyfork.send(...)` with that `template_id`. Done.
299
+
300
+ ---
301
+
302
+ ### Reliability
303
+
304
+ - **Exponential backoff**: 60s → 120s → 240s, capped at 10 minutes
305
+ - **`acks_late=True`**: task only acknowledged after completion; safe on worker crash
306
+ - **Beat sweep**: periodic task re-queues notifications stuck in `RETRYING`
307
+ - **N+1 safe**: all queries are bounded with `LIMIT`
308
+ - **Provider fallback**: if you register more than one provider for the same
309
+ channel (e.g. SendGrid + SMTP for email), a failure on the first one falls
310
+ through to the next immediately, no wait for the retry backoff. Order is
311
+ explicit, not whatever `Container.providers()` happened to build first:
312
+ defaults to `DEFAULT_PROVIDER_ORDER` in `container/providers.py`, override
313
+ with `NOTIFYFORK_PROVIDER_ORDER=sendgrid_email,smtp_email`. Whichever
314
+ provider actually sent it is always recorded in `notification.provider_used`.
315
+
316
+ ---
317
+
318
+ ### Running tests
319
+
320
+ ```bash
321
+ pytest tests/unit -v --cov=notifyfork
322
+ # Coverage gate: 80% minimum, see CONTRIBUTING.md
323
+ ```
324
+
325
+ ---
326
+
327
+ <a name="português"></a>
328
+ ## 🇧🇷 Português
329
+
330
+ ### O que é o NotifyFork?
331
+
332
+ Chamada direta ao Twilio espalhada pelo código vira bagunça rápido: lógica de retry duplicada, e daí alguém precisa de WhatsApp e tudo desmorona.
333
+
334
+ O NotifyFork é uma camada de entrega fina e agnóstica de provider. Você já sabe qual canal e template quer usar; ele escolhe o provider certo, enfileira o envio, faz retry em caso de falha, e loga tudo em JSON estruturado.
335
+
336
+ ```bash
337
+ pip install notifyfork
338
+ ```
339
+
340
+ ```python
341
+ import notifyfork
342
+
343
+ notifyfork.send(
344
+ recipient="+5511999999999",
345
+ channel="sms",
346
+ template_id="otp_sms",
347
+ notification_type="transactional",
348
+ context={"code": "847291"},
349
+ )
350
+ # → enfileirado no Celery, com retry em caso de falha
351
+ ```
352
+
353
+ Essa é toda a API. Seleção de provider, renderização de template e retry acontecem atrás da fila.
354
+
355
+ ---
356
+
357
+ ### Canais e providers
358
+
359
+ | Canal | Provider | Modo de template |
360
+ |-------|----------|-----------------|
361
+ | SMS | Twilio | Local (texto livre) |
362
+ | E-mail | SendGrid | Local **ou** Externo (Dynamic Templates) |
363
+ | E-mail | Resend | Local (HTML renderizado aqui) |
364
+ | E-mail | SMTP (qualquer servidor) | Local (HTML renderizado aqui) |
365
+ | WhatsApp | Twilio | Local (sandbox) **ou** Externo (Content Templates) |
366
+ | Push | Firebase Cloud Messaging | Local (título + body) |
367
+ | Slack | Slack Web API | Local (texto simples ou Block Kit) |
368
+
369
+ Mais de um provider por canal cai um pro outro automaticamente (veja "Confiabilidade"). Adicionar um provider é uma classe nova — veja "Adicionando um provider" abaixo. `channel` também não fica preso a essa tabela; dá pra registrar um provider pra um canal que o NotifyFork nem conhece.
370
+
371
+ **`channel` genérico vs. explícito**: todo provider nativo aceita `channel` de duas formas — a genérica (`"whatsapp"`, `"email"`, `"sms"`) e o próprio nome `vendor_canal` (`twilio_whatsapp`, `sendgrid_email`, `twilio_sms`...), porque `supported_channels` lista as duas:
372
+
373
+ ```python
374
+ notifyfork.send(channel="whatsapp", ...) # genérico — elegível pra fallback se
375
+ # outro provider de WhatsApp for registrado
376
+ notifyfork.send(channel="twilio_whatsapp", ...) # explícito — fixa a Twilio, sem fallback
377
+ ```
378
+
379
+ Usa o genérico quando quer que o NotifyFork escolha/caia pro próximo entre os providers registrados pro canal (ordenado por `DEFAULT_PROVIDER_ORDER` / `NOTIFYFORK_PROVIDER_ORDER`, gravado em `notification.provider_used`). Usa a forma explícita `vendor_canal` quando precisa daquele vendor especificamente — ex: um template em modo **EXTERNO** guarda um ID específico do vendor (Content SID da Twilio, Dynamic Template ID do SendGrid) que só o provider que emitiu entende, então fixar o canal só documenta esse travamento em vez de sugerir um fallback que não funcionaria de qualquer jeito. `slack` é o único provider sem uma forma explícita separada: só existe uma integração Slack (a Web API), e vendor e canal já são a mesma palavra, então `slack_slack` não acrescentaria nada. Veja os [exemplos](examples/) rodáveis com as duas formas em contexto.
380
+
381
+ ---
382
+
383
+ ### Modos de template
384
+
385
+ **LOCAL**: o body é renderizado aqui usando `string.Template` do Python.
386
+
387
+ ```python
388
+ body = "Seu código é: $code"
389
+ context = {"code": "847291"}
390
+ # → "Seu código é: 847291"
391
+ ```
392
+
393
+ **EXTERNO**: o body é o ID do template no provider. As variáveis são traduzidas via `VariableMapping` antes do envio.
394
+
395
+ ```python
396
+ # SendGrid Dynamic Template
397
+ body = "d-abc123def456"
398
+ variable_mapping = {"name": "customer_name", "total": "order_total"}
399
+
400
+ # Twilio WhatsApp Content Template (posicional)
401
+ body = "HXabc123def456"
402
+ variable_mapping = {"name": "1", "code": "2"}
403
+ ```
404
+
405
+ ---
406
+
407
+ ### Começando
408
+
409
+ ```bash
410
+ pip install notifyfork
411
+ ```
412
+
413
+ ```python
414
+ # settings.py
415
+ INSTALLED_APPS = [
416
+ ...,
417
+ "notifyfork.core.infrastructure",
418
+ ]
419
+ ```
420
+
421
+ ```python
422
+ # urls.py (opcional, só necessário pra receber os webhooks de entrega dos providers)
423
+ urlpatterns = [
424
+ ...,
425
+ path("api/v1/", include("notifyfork.api.urls")),
426
+ ]
427
+ ```
428
+
429
+ ```bash
430
+ cp .env.example .env # preencha as credenciais dos providers que vai usar
431
+ python manage.py migrate
432
+ celery -A seuprojeto worker --loglevel=info # seu próprio Celery, descobre as tasks do NotifyFork
433
+ ```
434
+
435
+ Depois é só testar com um dos [exemplos executáveis](examples/) via `python manage.py shell`.
436
+
437
+ ---
438
+
439
+ ### Enviando uma notificação
440
+
441
+ **Mesmo projeto**: chama `notifyfork.send(...)` direto em Python, como no exemplo acima. Sem HTTP.
442
+
443
+ **Outro serviço** (outro microserviço seu, outra linguagem): o NotifyFork propositalmente não expõe endpoint HTTP público — auth muda por deploy, não é algo que uma lib deveria decidir por você. Em vez disso, crie uma view fina e autenticada no seu próprio projeto:
444
+
445
+ ```python
446
+ # seuprojeto/notifications/views.py
447
+ from rest_framework.views import APIView
448
+ from rest_framework.response import Response
449
+ from rest_framework.permissions import IsAuthenticated # ou a auth que você usa
450
+ import notifyfork
451
+
452
+ class SendNotificationView(APIView):
453
+ permission_classes = [IsAuthenticated]
454
+
455
+ def post(self, request):
456
+ task = notifyfork.send(
457
+ recipient=request.data["recipient"],
458
+ channel=request.data["channel"],
459
+ template_id=request.data["template_id"],
460
+ notification_type=request.data["notification_type"],
461
+ context=request.data.get("context", {}),
462
+ )
463
+ return Response({"task_id": task.id}, status=202)
464
+ ```
465
+
466
+ Outros serviços mandam POST pra URL e esquema de auth que você escolheu — você controla o contrato, o NotifyFork só cuida do envio por trás.
467
+
468
+ Os webhooks de confirmação de entrega (`notifyfork.api.webhooks`) são a única exceção feita pra montar direto: eles validam a assinatura do próprio provider (Twilio, SendGrid, Resend), então não dependem da auth da sua aplicação.
469
+
470
+ ---
471
+
472
+ ### Adicionando um provider
473
+
474
+ **Dentro da lib** — herda de `NotificationProvider` e registra no `container/providers.py`:
475
+
476
+ ```python
477
+ class MeuVendorSMSProvider(NotificationProvider):
478
+ @property
479
+ def name(self) -> str:
480
+ return "meuvendor_sms" # vendor_canal — veja "channel vs. provider.name" acima
481
+
482
+ @property
483
+ def supported_channels(self) -> list[NotificationChannel]:
484
+ return [NotificationChannel.SMS]
485
+
486
+ async def send_with_template(self, recipient, template, context) -> ProviderResult:
487
+ body = template.render(context)
488
+ # sua chamada de API aqui
489
+ ...
490
+ ```
491
+
492
+ **Do seu próprio projeto** — sem herdar nada, sem mexer no container, só decora uma classe comum (duck-typing, só `.name` e `.send_with_template()` são usados):
493
+
494
+ ```python
495
+ import notifyfork
496
+
497
+ @notifyfork.provider
498
+ class TelegramProvider:
499
+ name = "telegram" # vendor == canal aqui (igual "slack"), sem sufixo _canal
500
+ supported_channels = ["telegram"] # channel não é enum fechado, qualquer string serve
501
+
502
+ def supports(self, channel):
503
+ return channel in self.supported_channels
504
+
505
+ async def send_with_template(self, recipient, template, context):
506
+ ...
507
+
508
+ notifyfork.send(recipient="@someone", channel="telegram", template_id="greeting", notification_type="transactional")
509
+ ```
510
+
511
+ A classe é instanciada sem argumentos e adicionada em `Container.providers()`.
512
+ Veja [`examples/custom_provider`](examples/custom_provider).
513
+
514
+ ---
515
+
516
+ ### Adicionando um tipo de notificação
517
+
518
+ Não existe catálogo de eventos. Escolhe um `channel`, cria o template via migration, chama `notifyfork.send(...)` com esse `template_id`. Pronto.
519
+
520
+ ---
521
+
522
+ ### Confiabilidade
523
+
524
+ - **Backoff exponencial**: 60s → 120s → 240s, limite de 10 minutos
525
+ - **`acks_late=True`**: task só confirmada após conclusão; seguro em caso de crash do worker
526
+ - **Sweep periódico**: task beat re-enfileira notificações travadas em `RETRYING`
527
+ - **Seguro contra N+1**: todas as queries têm `LIMIT`
528
+ - **Fallback entre providers**: se você registrar mais de um provider pro
529
+ mesmo canal (ex: SendGrid + SMTP pra email), uma falha no primeiro cai pro
530
+ próximo na hora, sem esperar o backoff do retry. A ordem é explícita, não é
531
+ "o que o `Container.providers()` montou primeiro": usa `DEFAULT_PROVIDER_ORDER`
532
+ em `container/providers.py` por padrão, dá pra sobrescrever com
533
+ `NOTIFYFORK_PROVIDER_ORDER=sendgrid_email,smtp_email`. Qual provider
534
+ realmente enviou fica sempre registrado em `notification.provider_used`.
535
+
536
+ ---
537
+
538
+ ### Rodando os testes
539
+
540
+ ```bash
541
+ pytest tests/unit -v --cov=notifyfork
542
+ # Meta de cobertura: mínimo 80%, aplicado no CI
543
+ ```
544
+
545
+ ---
546
+
547
+ ## 🗂 Estrutura do projeto
548
+
549
+ ```
550
+ notifyfork/
551
+ ├── notifyfork/ ← o pacote publicado (isto que vira "pip install notifyfork")
552
+ │ ├── api/ ← Views Django, serializers, webhooks
553
+ │ └── core/
554
+ │ ├── domain/ ← Entidades, value objects, domain events
555
+ │ ├── application/ ← Use cases, interfaces, DTOs
556
+ │ └── infrastructure/ ← Providers, repositories, Celery tasks, container
557
+ ├── examples/ ← Exemplos executáveis por canal
558
+ │ ├── sms/
559
+ │ ├── email/
560
+ │ ├── whatsapp/
561
+ │ ├── push/
562
+ │ └── slack/
563
+ └── tests/
564
+ ├── conftest.py ← Fixtures compartilhadas
565
+ └── unit/
566
+ ```
567
+
568
+ ---
569
+
570
+ ## 🤝 Contributing / Contribuindo
571
+
572
+ Contributions are welcome! / Contribuições são bem-vindas!
573
+
574
+ - Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR
575
+ - Open an [issue](https://github.com/marioasaraujo/notifyfork/issues) to discuss new features or bugs
576
+ - PRs for new providers, bug fixes, and documentation improvements are always appreciated
577
+
578
+ ---
579
+
580
+ ## 📬 Contact / Contato
581
+
582
+ **Mario Araujo**
583
+
584
+ [![LinkedIn](https://img.shields.io/badge/LinkedIn-0077B5?style=flat-square&logo=linkedin&logoColor=white)](https://linkedin.com/in/marioasaraujo)
585
+ [![GitHub](https://img.shields.io/badge/GitHub-181717?style=flat-square&logo=github&logoColor=white)](https://github.com/marioasaraujo)
586
+ [![Email](https://img.shields.io/badge/Email-D14836?style=flat-square&logo=gmail&logoColor=white)](mailto:marioasaraujo@gmail.com)
587
+
588
+ > Found a bug? Open an [issue](https://github.com/marioasaraujo/notifyfork/issues).
589
+ > Want to collaborate or hire me for a project? Reach out on LinkedIn.
590
+
591
+ ---
592
+
593
+ ## 📄 License
594
+
595
+ MIT © [Mario Araujo](https://github.com/marioasaraujo)
596
+
597
+ Veja [LICENSE](LICENSE) para mais detalhes.
598
+
599
+ <img width="100%" src="https://capsule-render.vercel.app/api?type=waving&color=0:2c5364,50:203a43,100:0f2027&height=60&section=footer"/>
@@ -0,0 +1,60 @@
1
+ notifyfork/__init__.py,sha256=MPV00jqoF_LYLc7zEdQBQU5MVKIvgWH_lL9C35JJHbo,139
2
+ notifyfork/client.py,sha256=C6Vl7nHXl3HQzLvSpE8fYPxP7O_PGT8eSRbehaB7BCQ,1865
3
+ notifyfork/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ notifyfork/api/urls.py,sha256=P6MMxblenabhuccvdymJKxFpZ1x_A4XC9e_78hA2K0M,121
5
+ notifyfork/api/webhooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ notifyfork/api/webhooks/resend_webhook.py,sha256=Yrqi1pf30_kBh8CML_Ug2ez8iQRt2JqEM12DGlKQsS4,5200
7
+ notifyfork/api/webhooks/sendgrid_webhook.py,sha256=Dkm-dgtvw2B0kCnr8CSGN5IOEiKL5p7cbUlq9IU7QIA,4703
8
+ notifyfork/api/webhooks/tasks.py,sha256=mPBDT1qQO7dDRNECJC_9yKfuQxTDdDguiwUeIrigaS0,2439
9
+ notifyfork/api/webhooks/twilio_webhook.py,sha256=HTUhJFa_lkCdcegedyJfFdRirlZ_1I0GEF9wliI0S5k,4952
10
+ notifyfork/api/webhooks/urls.py,sha256=yyh_W7CGELOposHjRlQKiHav6DqfCGtim3BGMQ0z-84,571
11
+ notifyfork/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ notifyfork/core/application/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ notifyfork/core/application/dtos/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ notifyfork/core/application/dtos/send_notification_dto.py,sha256=ul8nIETl21FwM8X6I4AG7bpeyHPtebPxkOOm_oQxORk,677
15
+ notifyfork/core/application/interfaces/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ notifyfork/core/application/interfaces/notification_provider.py,sha256=hAGWUkGH42WxUTAZkktKHhDLUCMbfJombgkCVaupyJ8,1142
17
+ notifyfork/core/application/interfaces/notification_repository.py,sha256=MG70_7tkyJ7bFE_JRetipIaK8vWMMpYZrv0ZaymtI5Q,460
18
+ notifyfork/core/application/interfaces/template_repository.py,sha256=BnWm8KupfF3ygjpMqvTmIU9WFbqyCZTJQK-GMVu9Ipg,253
19
+ notifyfork/core/application/use_cases/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ notifyfork/core/application/use_cases/send_notification.py,sha256=TFx7IB67jDrqMTU2oYfLHz7JLIWXS2gcnR5PUpG2Uoo,3508
21
+ notifyfork/core/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ notifyfork/core/domain/entities/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
+ notifyfork/core/domain/entities/notification.py,sha256=AiVCylpgk2u2ZPRp5L63ozSJHNXJVbCTWQ-I1tVpJKI,5374
24
+ notifyfork/core/domain/events/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ notifyfork/core/domain/events/notification_events.py,sha256=gH2b4OfkWG0P3TrkOn2N-21aWwlCDf_juTx7U5MIxDg,614
26
+ notifyfork/core/domain/value_objects/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
+ notifyfork/core/domain/value_objects/template.py,sha256=39y9KMqErsHCciDVAIP3AhYw0INgpumqX650LQjFVpM,3503
28
+ notifyfork/core/infrastructure/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
29
+ notifyfork/core/infrastructure/apps.py,sha256=sqUmVWP76EsNo132KzcEfFXNtvB62z4cEmJd6KXabKs,201
30
+ notifyfork/core/infrastructure/container/__init__.py,sha256=17xoeeFxTi0qxGUDnIwMRHFYcRzpfU6EdmnMw3Ml60Y,98
31
+ notifyfork/core/infrastructure/container/providers.py,sha256=sMPGzFeR7gpME3aPlGL6-1HYc-ih8FoBLeaFVFc__8I,8480
32
+ notifyfork/core/infrastructure/migrations/0001_initial.py,sha256=-JXkIRhj6xicO-hbs6-kVg_97qbLDtmnEWkaRS9CJlA,2259
33
+ notifyfork/core/infrastructure/migrations/0002_seed_templates.py,sha256=tj1p04arZpE9aVncX85s3S3UMszAcGUrcaSHJDNRzUU,3704
34
+ notifyfork/core/infrastructure/migrations/0003_delivery_status.py,sha256=fe1XamOLTs1pETuHrZMhu2dC0oF_TNA1NnUDyaiTGCs,1710
35
+ notifyfork/core/infrastructure/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
+ notifyfork/core/infrastructure/models/__init__.py,sha256=cqzcYMLNrjiONxqi_y73r_palZ2ohkxBr6E6nygrRK0,189
37
+ notifyfork/core/infrastructure/models/notification_model.py,sha256=PXOJpOed8yF3KTM6s8mfMgLia2w_BMj_TtXXDbfRQCw,3625
38
+ notifyfork/core/infrastructure/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
+ notifyfork/core/infrastructure/providers/firebase_provider.py,sha256=QHHIhtMruIspHvP4-zUfuGmZiCmCqHxy3YwOLQ3Ds4Q,2224
40
+ notifyfork/core/infrastructure/providers/resend_provider.py,sha256=sdph3OKX5oiTCFNOE_U8VIic6FOLOVg_Ya9eX4ONV9s,3016
41
+ notifyfork/core/infrastructure/providers/sendgrid_provider.py,sha256=iPj_WMFo_9DitMbWEJuWDYxQ0d_iPQ_FdBuD3g1MfSM,5622
42
+ notifyfork/core/infrastructure/providers/slack_provider.py,sha256=1ZKnYeRsQntsH0XyCnMabdtT69kFOvLF1RbhX3sNlHQ,3530
43
+ notifyfork/core/infrastructure/providers/smtp_provider.py,sha256=BtNdEtmVBGbSeG04W_MNdkGkxERjH71wWpZgc9AgJ5M,2423
44
+ notifyfork/core/infrastructure/providers/twilio_provider.py,sha256=BrsfOqtTlYvhtrkdleG3J5hDd2B8mQ8iJFQmv7A7v78,2053
45
+ notifyfork/core/infrastructure/providers/whatsapp_provider.py,sha256=FwfjEVYZLg6sVMWeJvbmYihuS6amuCsZlft_FC8XqmM,5479
46
+ notifyfork/core/infrastructure/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
+ notifyfork/core/infrastructure/queue/tasks.py,sha256=pApZVvivuKFeQ5BLWs6XoNmwotrpbfPeHgEhZXURSqc,2785
48
+ notifyfork/core/infrastructure/repositories/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
+ notifyfork/core/infrastructure/repositories/notification_repository.py,sha256=K-nfImI3PBj4pyz-TWtp2GCs7M9aRBNBMO0FnPYnm5M,2796
50
+ notifyfork/core/infrastructure/repositories/template_repository.py,sha256=JZ9KotPvZN9B_nQQdebA-BrhUZk-8Rel-qAqndv3vlE,1038
51
+ notifyfork/shared/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
+ notifyfork/shared/exceptions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
+ notifyfork/shared/exceptions/provider_exceptions.py,sha256=lwCqpHMNqTubvmoyuQnXMhzXw-_0-JJiHYzbfxg152U,763
54
+ notifyfork/shared/logging/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
+ notifyfork/shared/logging/setup.py,sha256=bkm2SJPifTF4IkWMq_vuLdnJrq6O5Rtq58FnWbOcKb0,1099
56
+ notifyfork/shared/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
+ notifyfork-0.1.2.dist-info/METADATA,sha256=uomRXCuPNhgCNFS3U0T43tbF4at5__nVl2fDUJplDPU,23753
58
+ notifyfork-0.1.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
59
+ notifyfork-0.1.2.dist-info/licenses/LICENSE,sha256=RuRSYCysHE6bSxocKxlVfKTutkE0pCl-H8-pEhmk468,1069
60
+ notifyfork-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any