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,116 @@
1
+ """
2
+ Seed migration — inserts the default templates that ship with NotifyFork.
3
+
4
+ These are starter templates. Edit them in the database or via Django admin
5
+ to match your product's copy and branding.
6
+ """
7
+ from django.db import migrations
8
+
9
+
10
+ TEMPLATES = [
11
+ # SMS
12
+ {
13
+ "id": "otp_sms",
14
+ "body": "Your NotifyFork verification code is: $code. Valid for 10 minutes.",
15
+ "subject": None,
16
+ "mode": "local",
17
+ "variable_mapping": {},
18
+ },
19
+ {
20
+ "id": "payment_failed_sms",
21
+ "body": "Payment of $amount failed for order $order_id. Please update your payment method.",
22
+ "subject": None,
23
+ "mode": "local",
24
+ "variable_mapping": {},
25
+ },
26
+
27
+ # Email (local HTML)
28
+ {
29
+ "id": "email_verification",
30
+ "body": "<p>Hi $name,</p><p>Click <a href='$link'>here</a> to verify your email.</p>",
31
+ "subject": "Verify your email address",
32
+ "mode": "local",
33
+ "variable_mapping": {},
34
+ },
35
+ {
36
+ "id": "order_confirmed",
37
+ "body": "<p>Hi $name,</p><p>Your order <strong>$order_id</strong> is confirmed. Total: $total</p>",
38
+ "subject": "Order $order_id confirmed",
39
+ "mode": "local",
40
+ "variable_mapping": {},
41
+ },
42
+
43
+ # WhatsApp (Twilio external template)
44
+ # body = Twilio Content Template SID
45
+ # variable_mapping translates context keys to positional Twilio variables
46
+ {
47
+ "id": "order_shipped_wa",
48
+ "body": "REPLACE_WITH_YOUR_TWILIO_CONTENT_SID",
49
+ "subject": None,
50
+ "mode": "external",
51
+ "variable_mapping": {"name": "1", "tracking_code": "2", "carrier": "3"},
52
+ },
53
+
54
+ # Email (SendGrid external template)
55
+ # body = SendGrid Dynamic Template ID (d-xxxx)
56
+ {
57
+ "id": "order_confirmed_sg",
58
+ "body": "REPLACE_WITH_YOUR_SENDGRID_TEMPLATE_ID",
59
+ "subject": None,
60
+ "mode": "external",
61
+ "variable_mapping": {
62
+ "name": "customer_name",
63
+ "order_id": "order_id",
64
+ "total": "order_total",
65
+ },
66
+ },
67
+
68
+ # Push (Firebase, rendered locally)
69
+ {
70
+ "id": "flash_sale_push",
71
+ "body": "Hurry! $discount OFF everything. Ends in $expires_in.",
72
+ "subject": "Flash Sale — $discount OFF",
73
+ "mode": "local",
74
+ "variable_mapping": {},
75
+ },
76
+ {
77
+ "id": "pickup_ready_push",
78
+ "body": "Your order $order_id is ready for pickup at $location.",
79
+ "subject": "Your order is ready!",
80
+ "mode": "local",
81
+ "variable_mapping": {},
82
+ },
83
+
84
+ # Slack (rendered locally with Markdown)
85
+ {
86
+ "id": "system_error_slack",
87
+ "body": "*Service:* $service\n*Error:* $error\n*Environment:* $env",
88
+ "subject": "🚨 Error in $service",
89
+ "mode": "local",
90
+ "variable_mapping": {},
91
+ },
92
+ {
93
+ "id": "deploy_done_slack",
94
+ "body": "*Service:* $service deployed successfully\n*Version:* $version\n*By:* $author",
95
+ "subject": "✅ Deploy completed — $service",
96
+ "mode": "local",
97
+ "variable_mapping": {},
98
+ },
99
+ ]
100
+
101
+
102
+ def seed_templates(apps, schema_editor):
103
+ NotificationTemplateModel = apps.get_model("notifyfork", "NotificationTemplateModel")
104
+ for t in TEMPLATES:
105
+ NotificationTemplateModel.objects.get_or_create(id=t["id"], defaults=t)
106
+
107
+
108
+ def unseed_templates(apps, schema_editor):
109
+ NotificationTemplateModel = apps.get_model("notifyfork", "NotificationTemplateModel")
110
+ ids = [t["id"] for t in TEMPLATES]
111
+ NotificationTemplateModel.objects.filter(id__in=ids).delete()
112
+
113
+
114
+ class Migration(migrations.Migration):
115
+ dependencies = [("notifyfork", "0001_initial")]
116
+ operations = [migrations.RunPython(seed_templates, unseed_templates)]
@@ -0,0 +1,50 @@
1
+ """
2
+ Migration: adds DELIVERED and DELIVERY_FAILED statuses,
3
+ plus provider_message_id and delivered_at fields.
4
+
5
+ provider_message_id is indexed — webhook lookups query by this field.
6
+ """
7
+ from django.db import migrations, models
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+
12
+ dependencies = [("notifyfork", "0002_seed_templates")]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name="NotificationModel",
17
+ name="provider_message_id",
18
+ field=models.CharField(
19
+ max_length=255,
20
+ null=True,
21
+ blank=True,
22
+ db_index=True, # indexed — webhook lookup by this field
23
+ help_text="External ID returned by provider at send time (e.g. Twilio MessageSid)",
24
+ ),
25
+ ),
26
+ migrations.AddField(
27
+ model_name="NotificationModel",
28
+ name="delivered_at",
29
+ field=models.DateTimeField(null=True, blank=True),
30
+ ),
31
+ # Update status choices to include new states
32
+ migrations.AlterField(
33
+ model_name="NotificationModel",
34
+ name="status",
35
+ field=models.CharField(
36
+ max_length=20,
37
+ default="pending",
38
+ db_index=True,
39
+ choices=[
40
+ ("pending", "Pending"),
41
+ ("queued", "Queued"),
42
+ ("sent", "Sent"),
43
+ ("delivered", "Delivered"),
44
+ ("delivery_failed", "Delivery Failed"),
45
+ ("failed", "Failed"),
46
+ ("retrying", "Retrying"),
47
+ ],
48
+ ),
49
+ ),
50
+ ]
File without changes
@@ -0,0 +1,6 @@
1
+ from notifyfork.core.infrastructure.models.notification_model import (
2
+ NotificationModel,
3
+ NotificationTemplateModel,
4
+ )
5
+
6
+ __all__ = ["NotificationModel", "NotificationTemplateModel"]
@@ -0,0 +1,98 @@
1
+ import uuid
2
+ from django.db import models
3
+
4
+
5
+ class NotificationModel(models.Model):
6
+ """
7
+ Django ORM model for persisting Notification state.
8
+
9
+ Intentionally separate from the domain entity —
10
+ the domain entity owns behavior, this model owns persistence.
11
+ """
12
+
13
+ class StatusChoices(models.TextChoices):
14
+ PENDING = "pending"
15
+ QUEUED = "queued"
16
+ SENT = "sent"
17
+ DELIVERED = "delivered"
18
+ DELIVERY_FAILED = "delivery_failed"
19
+ FAILED = "failed"
20
+ RETRYING = "retrying"
21
+
22
+ class ChannelChoices(models.TextChoices):
23
+ SMS = "sms"
24
+ EMAIL = "email"
25
+ PUSH = "push"
26
+ WHATSAPP = "whatsapp"
27
+ SLACK = "slack"
28
+
29
+ class TypeChoices(models.TextChoices):
30
+ TRANSACTIONAL = "transactional"
31
+ ALERT = "alert"
32
+ MARKETING = "marketing"
33
+
34
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
35
+ recipient = models.CharField(max_length=255)
36
+ channel = models.CharField(max_length=20, choices=ChannelChoices.choices)
37
+ notification_type = models.CharField(max_length=20, choices=TypeChoices.choices)
38
+ template_id = models.CharField(max_length=100)
39
+ context = models.JSONField(default=dict)
40
+ status = models.CharField(
41
+ max_length=20, choices=StatusChoices.choices, default=StatusChoices.PENDING, db_index=True
42
+ )
43
+ provider_used = models.CharField(max_length=50, null=True, blank=True)
44
+ provider_message_id = models.CharField(
45
+ max_length=255,
46
+ null=True,
47
+ blank=True,
48
+ db_index=True,
49
+ help_text="External ID returned by provider at send time (e.g. Twilio MessageSid)",
50
+ )
51
+ attempts = models.PositiveSmallIntegerField(default=0)
52
+ max_attempts = models.PositiveSmallIntegerField(default=3)
53
+ error_detail = models.TextField(null=True, blank=True)
54
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
55
+ sent_at = models.DateTimeField(null=True, blank=True)
56
+ delivered_at = models.DateTimeField(null=True, blank=True)
57
+
58
+ class Meta:
59
+ app_label = "notifyfork"
60
+ db_table = "notifyfork_notifications"
61
+ ordering = ["-created_at"]
62
+ indexes = [
63
+ # Most common query: retrying notifications for the sweep task
64
+ models.Index(fields=["status", "attempts"], name="idx_status_attempts"),
65
+ ]
66
+
67
+ def __str__(self) -> str:
68
+ return f"Notification({self.channel} → {self.recipient[:8]}*** [{self.status}])"
69
+
70
+
71
+ class NotificationTemplateModel(models.Model):
72
+ """
73
+ Stores templates in the database.
74
+ Supports both LOCAL (body = content) and EXTERNAL (body = provider template ID) modes.
75
+ """
76
+
77
+ class ModeChoices(models.TextChoices):
78
+ LOCAL = "local"
79
+ EXTERNAL = "external"
80
+
81
+ id = models.CharField(max_length=100, primary_key=True) # e.g. "otp_sms"
82
+ body = models.TextField() # content or external template ID
83
+ subject = models.CharField(max_length=255, null=True, blank=True)
84
+ mode = models.CharField(max_length=10, choices=ModeChoices.choices, default=ModeChoices.LOCAL)
85
+ variable_mapping = models.JSONField(
86
+ default=dict,
87
+ help_text='Maps context keys to provider keys. e.g. {"name": "1", "code": "2"} for Twilio WA',
88
+ )
89
+ is_active = models.BooleanField(default=True)
90
+ created_at = models.DateTimeField(auto_now_add=True)
91
+ updated_at = models.DateTimeField(auto_now=True)
92
+
93
+ class Meta:
94
+ app_label = "notifyfork"
95
+ db_table = "notifyfork_templates"
96
+
97
+ def __str__(self) -> str:
98
+ return f"Template({self.id} [{self.mode}])"
File without changes
@@ -0,0 +1,69 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ import firebase_admin
5
+ from firebase_admin import messaging
6
+ from firebase_admin.exceptions import FirebaseError
7
+
8
+ from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
9
+ from notifyfork.core.domain.entities.notification import NotificationChannel
10
+ from notifyfork.core.domain.value_objects.template import NotificationTemplate
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class FirebasePushProvider(NotificationProvider):
16
+ """
17
+ Firebase Cloud Messaging provider for push notifications.
18
+
19
+ Recipient is expected to be a valid FCM device token.
20
+ """
21
+
22
+ @property
23
+ def name(self) -> str:
24
+ return "firebase_push"
25
+
26
+ @property
27
+ def supported_channels(self) -> list[NotificationChannel]:
28
+ # "push" — generic, eligible for fallback if another push provider is
29
+ # registered. "firebase_push" (== self.name) — pins this exact vendor.
30
+ return [NotificationChannel.PUSH, self.name]
31
+
32
+ async def send_with_template(
33
+ self,
34
+ recipient: str,
35
+ template: NotificationTemplate,
36
+ context: dict[str, Any],
37
+ ) -> ProviderResult:
38
+ body = template.render(context)
39
+ subject = template.render_subject(context)
40
+ return await self.send(recipient=recipient, body=body, subject=subject)
41
+
42
+ async def send(
43
+ self,
44
+ recipient: str,
45
+ body: str,
46
+ subject: str | None = None,
47
+ **kwargs,
48
+ ) -> ProviderResult:
49
+ try:
50
+ message = messaging.Message(
51
+ notification=messaging.Notification(
52
+ title=subject or "Notification",
53
+ body=body,
54
+ ),
55
+ token=recipient,
56
+ )
57
+ response = messaging.send(message)
58
+ return ProviderResult(
59
+ success=True,
60
+ provider_name=self.name,
61
+ external_id=response,
62
+ )
63
+ except FirebaseError as e:
64
+ logger.error("Firebase error", extra={"error": str(e)})
65
+ return ProviderResult(
66
+ success=False,
67
+ provider_name=self.name,
68
+ error=str(e),
69
+ )
@@ -0,0 +1,83 @@
1
+ import logging
2
+ import httpx
3
+ from typing import Any
4
+
5
+ from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
6
+ from notifyfork.core.domain.entities.notification import NotificationChannel
7
+ from notifyfork.core.domain.value_objects.template import NotificationTemplate
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ RESEND_API_URL = "https://api.resend.com/emails"
12
+
13
+
14
+ class ResendEmailProvider(NotificationProvider):
15
+ """
16
+ Resend email provider.
17
+
18
+ LOCAL mode only — renders body locally, sends as raw HTML. Resend
19
+ doesn't have a server-side dynamic template system like SendGrid; if
20
+ you need EXTERNAL mode, use SendGridEmailProvider instead.
21
+ """
22
+
23
+ def __init__(self, api_key: str, from_email: str, from_name: str = "") -> None:
24
+ self._api_key = api_key
25
+ self._from_email = from_email
26
+ self._from_name = from_name
27
+
28
+ @property
29
+ def name(self) -> str:
30
+ return "resend_email"
31
+
32
+ @property
33
+ def supported_channels(self) -> list[NotificationChannel]:
34
+ # "email" — generic, eligible for fallback to sendgrid_email/smtp_email.
35
+ # "resend_email" (== self.name) — pins this exact vendor.
36
+ return [NotificationChannel.EMAIL, self.name]
37
+
38
+ async def send_with_template(
39
+ self,
40
+ recipient: str,
41
+ template: NotificationTemplate,
42
+ context: dict[str, Any],
43
+ ) -> ProviderResult:
44
+ body = template.render(context)
45
+ subject = template.render_subject(context) or "(no subject)"
46
+ sender = f"{self._from_name} <{self._from_email}>" if self._from_name else self._from_email
47
+
48
+ payload = {
49
+ "from": sender,
50
+ "to": [recipient],
51
+ "subject": subject,
52
+ "html": body,
53
+ }
54
+
55
+ try:
56
+ async with httpx.AsyncClient(timeout=10) as client:
57
+ response = await client.post(
58
+ RESEND_API_URL,
59
+ headers={
60
+ "Authorization": f"Bearer {self._api_key}",
61
+ "Content-Type": "application/json",
62
+ },
63
+ json=payload,
64
+ )
65
+
66
+ if response.status_code in (200, 201):
67
+ message_id = response.json().get("id")
68
+ logger.info("Email sent via Resend", extra={"message_id": message_id})
69
+ return ProviderResult(success=True, provider_name=self.name, external_id=message_id)
70
+
71
+ logger.error(
72
+ "Resend error",
73
+ extra={"status": response.status_code, "body": response.text},
74
+ )
75
+ return ProviderResult(
76
+ success=False,
77
+ provider_name=self.name,
78
+ error=f"Resend [{response.status_code}]: {response.text}",
79
+ )
80
+
81
+ except httpx.HTTPError as e:
82
+ logger.error("Resend HTTP error", extra={"error": str(e)})
83
+ return ProviderResult(success=False, provider_name=self.name, error=str(e))
@@ -0,0 +1,150 @@
1
+ import logging
2
+ import httpx
3
+ from typing import Any
4
+
5
+ from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
6
+ from notifyfork.core.domain.entities.notification import NotificationChannel
7
+ from notifyfork.core.domain.value_objects.template import NotificationTemplate, TemplateMode
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ SENDGRID_API_URL = "https://api.sendgrid.com/v3/mail/send"
12
+
13
+
14
+ class SendGridEmailProvider(NotificationProvider):
15
+ """
16
+ SendGrid email provider.
17
+
18
+ LOCAL mode — renders body locally, sends as raw HTML.
19
+ EXTERNAL mode — uses a SendGrid Dynamic Template (d-xxxx).
20
+ Variables are translated via VariableMapping
21
+ and sent as `dynamic_template_data`.
22
+
23
+ SendGrid Dynamic Templates use Handlebars: {{name}}, {{order_id}}
24
+ Your context keys must match the template's variable names,
25
+ or use VariableMapping to translate them.
26
+
27
+ Example:
28
+ template = NotificationTemplate(
29
+ id="order_confirmed",
30
+ body="d-abc123def456", # SendGrid template ID
31
+ mode=TemplateMode.EXTERNAL,
32
+ variable_mapping=VariableMapping({
33
+ "order_id": "order_id", # same name — pass through
34
+ "total": "order_total", # renamed to match SendGrid template
35
+ "name": "customer_name",
36
+ })
37
+ )
38
+ # context: {"order_id": "ORD-1", "total": "R$100", "name": "Mario"}
39
+ # sent to SendGrid as: {"order_id": "ORD-1", "order_total": "R$100", "customer_name": "Mario"}
40
+ """
41
+
42
+ def __init__(self, api_key: str, from_email: str, from_name: str = "") -> None:
43
+ self._api_key = api_key
44
+ self._from_email = from_email
45
+ self._from_name = from_name
46
+
47
+ @property
48
+ def name(self) -> str:
49
+ return "sendgrid_email"
50
+
51
+ @property
52
+ def supported_channels(self) -> list[NotificationChannel]:
53
+ # "email" — generic, eligible for fallback to resend_email/smtp_email
54
+ # (LOCAL-mode templates only). "sendgrid_email" (== self.name) — pins
55
+ # this vendor, required for EXTERNAL-mode templates (Dynamic Template ID).
56
+ return [NotificationChannel.EMAIL, self.name]
57
+
58
+ async def send_with_template(
59
+ self,
60
+ recipient: str,
61
+ template: NotificationTemplate,
62
+ context: dict[str, Any],
63
+ ) -> ProviderResult:
64
+ if template.mode == TemplateMode.EXTERNAL:
65
+ return await self._send_external(recipient, template, context)
66
+ return await self._send_local(recipient, template, context)
67
+
68
+ async def _send_external(
69
+ self,
70
+ recipient: str,
71
+ template: NotificationTemplate,
72
+ context: dict[str, Any],
73
+ ) -> ProviderResult:
74
+ """Uses a SendGrid Dynamic Template with Handlebars variables."""
75
+ translated = template.translate_variables(context)
76
+
77
+ payload = {
78
+ "from": {"email": self._from_email, "name": self._from_name},
79
+ "personalizations": [
80
+ {
81
+ "to": [{"email": recipient}],
82
+ "dynamic_template_data": translated,
83
+ }
84
+ ],
85
+ "template_id": template.external_template_id,
86
+ }
87
+
88
+ logger.info(
89
+ "Sending email via SendGrid external template",
90
+ extra={
91
+ "template_id": template.external_template_id,
92
+ "to": recipient,
93
+ "variables": list(translated.keys()),
94
+ },
95
+ )
96
+
97
+ return await self._post(payload)
98
+
99
+ async def _send_local(
100
+ self,
101
+ recipient: str,
102
+ template: NotificationTemplate,
103
+ context: dict[str, Any],
104
+ ) -> ProviderResult:
105
+ """Renders HTML locally, sends as raw content."""
106
+ body = template.render(context)
107
+ subject = template.render_subject(context) or "(no subject)"
108
+
109
+ payload = {
110
+ "from": {"email": self._from_email, "name": self._from_name},
111
+ "personalizations": [{"to": [{"email": recipient}]}],
112
+ "subject": subject,
113
+ "content": [{"type": "text/html", "value": body}],
114
+ }
115
+
116
+ return await self._post(payload)
117
+
118
+ async def _post(self, payload: dict) -> ProviderResult:
119
+ try:
120
+ async with httpx.AsyncClient(timeout=10) as client:
121
+ response = await client.post(
122
+ SENDGRID_API_URL,
123
+ headers={
124
+ "Authorization": f"Bearer {self._api_key}",
125
+ "Content-Type": "application/json",
126
+ },
127
+ json=payload,
128
+ )
129
+
130
+ # SendGrid returns 202 on success, no body
131
+ if response.status_code == 202:
132
+ message_id = response.headers.get("X-Message-Id")
133
+ logger.info("Email sent via SendGrid", extra={"message_id": message_id})
134
+ return ProviderResult(
135
+ success=True, provider_name=self.name, external_id=message_id
136
+ )
137
+
138
+ logger.error(
139
+ "SendGrid error",
140
+ extra={"status": response.status_code, "body": response.text},
141
+ )
142
+ return ProviderResult(
143
+ success=False,
144
+ provider_name=self.name,
145
+ error=f"SendGrid [{response.status_code}]: {response.text}",
146
+ )
147
+
148
+ except httpx.HTTPError as e:
149
+ logger.error("SendGrid HTTP error", extra={"error": str(e)})
150
+ return ProviderResult(success=False, provider_name=self.name, error=str(e))
@@ -0,0 +1,108 @@
1
+ import logging
2
+ from typing import Any
3
+ import httpx
4
+
5
+ from notifyfork.core.application.interfaces.notification_provider import NotificationProvider, ProviderResult
6
+ from notifyfork.core.domain.entities.notification import NotificationChannel
7
+ from notifyfork.core.domain.value_objects.template import NotificationTemplate
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ SLACK_API_URL = "https://slack.com/api/chat.postMessage"
12
+
13
+
14
+ class SlackProvider(NotificationProvider):
15
+ """
16
+ Slack provider via Web API.
17
+
18
+ Recipient is a Slack channel ID or user ID (e.g. C012AB3CD, U012AB3CD).
19
+ Bot token must have chat:write scope.
20
+
21
+ Use for: internal alerts, ops notifications, system events.
22
+ Not for end-user transactional messages.
23
+ """
24
+
25
+ def __init__(self, bot_token: str) -> None:
26
+ self._token = bot_token
27
+
28
+ @property
29
+ def name(self) -> str:
30
+ return "slack"
31
+
32
+ @property
33
+ def supported_channels(self) -> list[NotificationChannel]:
34
+ # self.name == "slack" == NotificationChannel.SLACK.value already —
35
+ # generic and vendor-specific channel are the same string here, so
36
+ # there's no second entry to add (unlike twilio_whatsapp, sendgrid_email...).
37
+ return [NotificationChannel.SLACK]
38
+
39
+ async def send_with_template(
40
+ self,
41
+ recipient: str,
42
+ template: NotificationTemplate,
43
+ context: dict[str, Any],
44
+ ) -> ProviderResult:
45
+ body = template.render(context)
46
+ subject = template.render_subject(context)
47
+ return await self.send(recipient=recipient, body=body, subject=subject)
48
+
49
+ async def send(
50
+ self,
51
+ recipient: str,
52
+ body: str,
53
+ subject: str | None = None,
54
+ **kwargs,
55
+ ) -> ProviderResult:
56
+ payload = {
57
+ "channel": recipient,
58
+ "text": body,
59
+ }
60
+
61
+ # Optional: rich block format when subject is provided
62
+ if subject:
63
+ payload["blocks"] = [
64
+ {
65
+ "type": "header",
66
+ "text": {"type": "plain_text", "text": subject},
67
+ },
68
+ {
69
+ "type": "section",
70
+ "text": {"type": "mrkdwn", "text": body},
71
+ },
72
+ ]
73
+
74
+ try:
75
+ async with httpx.AsyncClient(timeout=10) as client:
76
+ response = await client.post(
77
+ SLACK_API_URL,
78
+ headers={"Authorization": f"Bearer {self._token}"},
79
+ json=payload,
80
+ )
81
+ data = response.json()
82
+
83
+ if not data.get("ok"):
84
+ error = data.get("error", "unknown_error")
85
+ logger.error("Slack API error", extra={"error": error, "channel": recipient})
86
+ return ProviderResult(
87
+ success=False,
88
+ provider_name=self.name,
89
+ error=f"Slack error: {error}",
90
+ )
91
+
92
+ logger.info(
93
+ "Slack message sent",
94
+ extra={"channel": recipient, "ts": data.get("ts")},
95
+ )
96
+ return ProviderResult(
97
+ success=True,
98
+ provider_name=self.name,
99
+ external_id=data.get("ts"),
100
+ )
101
+
102
+ except httpx.HTTPError as e:
103
+ logger.error("Slack HTTP error", extra={"error": str(e)})
104
+ return ProviderResult(
105
+ success=False,
106
+ provider_name=self.name,
107
+ error=f"HTTP error: {str(e)}",
108
+ )