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,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,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
|
+
)
|