python-missive 0.2.0__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.
- pymissive/__init__.py +3 -0
- pymissive/__main__.py +9 -0
- pymissive/archives/__init__.py +142 -0
- pymissive/archives/__main__.py +9 -0
- pymissive/archives/address.py +272 -0
- pymissive/archives/address_backends/__init__.py +29 -0
- pymissive/archives/address_backends/base.py +610 -0
- pymissive/archives/address_backends/geoapify.py +221 -0
- pymissive/archives/address_backends/geocode_earth.py +210 -0
- pymissive/archives/address_backends/google_maps.py +371 -0
- pymissive/archives/address_backends/here.py +348 -0
- pymissive/archives/address_backends/locationiq.py +271 -0
- pymissive/archives/address_backends/mapbox.py +314 -0
- pymissive/archives/address_backends/maps_co.py +257 -0
- pymissive/archives/address_backends/nominatim.py +348 -0
- pymissive/archives/address_backends/opencage.py +292 -0
- pymissive/archives/address_backends/pelias_mixin.py +181 -0
- pymissive/archives/address_backends/photon.py +322 -0
- pymissive/archives/cli.py +42 -0
- pymissive/archives/helpers.py +45 -0
- pymissive/archives/missive.py +64 -0
- pymissive/archives/providers/__init__.py +167 -0
- pymissive/archives/providers/apn.py +171 -0
- pymissive/archives/providers/ar24.py +204 -0
- pymissive/archives/providers/base/__init__.py +203 -0
- pymissive/archives/providers/base/_attachments.py +166 -0
- pymissive/archives/providers/base/branded.py +341 -0
- pymissive/archives/providers/base/common.py +781 -0
- pymissive/archives/providers/base/email.py +422 -0
- pymissive/archives/providers/base/email_message.py +85 -0
- pymissive/archives/providers/base/monitoring.py +150 -0
- pymissive/archives/providers/base/notification.py +187 -0
- pymissive/archives/providers/base/postal.py +742 -0
- pymissive/archives/providers/base/postal_defaults.py +26 -0
- pymissive/archives/providers/base/sms.py +213 -0
- pymissive/archives/providers/base/voice_call.py +82 -0
- pymissive/archives/providers/brevo.py +363 -0
- pymissive/archives/providers/certeurope.py +249 -0
- pymissive/archives/providers/django_email.py +182 -0
- pymissive/archives/providers/fcm.py +91 -0
- pymissive/archives/providers/laposte.py +392 -0
- pymissive/archives/providers/maileva.py +511 -0
- pymissive/archives/providers/mailgun.py +118 -0
- pymissive/archives/providers/messenger.py +74 -0
- pymissive/archives/providers/notification.py +112 -0
- pymissive/archives/providers/sendgrid.py +160 -0
- pymissive/archives/providers/ses.py +185 -0
- pymissive/archives/providers/signal.py +68 -0
- pymissive/archives/providers/slack.py +80 -0
- pymissive/archives/providers/smtp.py +190 -0
- pymissive/archives/providers/teams.py +91 -0
- pymissive/archives/providers/telegram.py +69 -0
- pymissive/archives/providers/twilio.py +310 -0
- pymissive/archives/providers/vonage.py +208 -0
- pymissive/archives/sender.py +339 -0
- pymissive/archives/status.py +22 -0
- pymissive/cli.py +42 -0
- pymissive/config.py +397 -0
- pymissive/helpers.py +0 -0
- pymissive/providers/apn.py +8 -0
- pymissive/providers/ar24.py +8 -0
- pymissive/providers/base/__init__.py +64 -0
- pymissive/providers/base/acknowledgement.py +6 -0
- pymissive/providers/base/attachments.py +10 -0
- pymissive/providers/base/branded.py +16 -0
- pymissive/providers/base/email.py +2 -0
- pymissive/providers/base/notification.py +2 -0
- pymissive/providers/base/postal.py +2 -0
- pymissive/providers/base/sms.py +2 -0
- pymissive/providers/base/voice_call.py +2 -0
- pymissive/providers/brevo.py +420 -0
- pymissive/providers/certeurope.py +8 -0
- pymissive/providers/django_email.py +8 -0
- pymissive/providers/fcm.py +8 -0
- pymissive/providers/laposte.py +8 -0
- pymissive/providers/maileva.py +8 -0
- pymissive/providers/mailgun.py +8 -0
- pymissive/providers/messenger.py +8 -0
- pymissive/providers/notification.py +8 -0
- pymissive/providers/partner.py +650 -0
- pymissive/providers/scaleway.py +498 -0
- pymissive/providers/sendgrid.py +8 -0
- pymissive/providers/ses.py +8 -0
- pymissive/providers/signal.py +8 -0
- pymissive/providers/slack.py +8 -0
- pymissive/providers/smtp.py +8 -0
- pymissive/providers/teams.py +8 -0
- pymissive/providers/telegram.py +8 -0
- pymissive/providers/twilio.py +8 -0
- pymissive/providers/vonage.py +8 -0
- python_missive-0.2.0.dist-info/METADATA +152 -0
- python_missive-0.2.0.dist-info/RECORD +95 -0
- python_missive-0.2.0.dist-info/WHEEL +5 -0
- python_missive-0.2.0.dist-info/entry_points.txt +2 -0
- python_missive-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Facebook Messenger provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..status import MissiveStatus
|
|
8
|
+
from .base import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MessengerProvider(BaseProvider):
|
|
12
|
+
"""
|
|
13
|
+
Facebook Messenger provider.
|
|
14
|
+
|
|
15
|
+
Required configuration:
|
|
16
|
+
MESSENGER_PAGE_ACCESS_TOKEN: Facebook page access token
|
|
17
|
+
MESSENGER_APP_SECRET: Application secret
|
|
18
|
+
|
|
19
|
+
Recipient must have a PSID (Page-Scoped ID) Messenger stored in metadata.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name = "messenger"
|
|
23
|
+
display_name = "Facebook Messenger"
|
|
24
|
+
supported_types = ["BRANDED"]
|
|
25
|
+
brands = ["messenger"] # Facebook Messenger only
|
|
26
|
+
config_keys = ["MESSENGER_PAGE_ACCESS_TOKEN", "MESSENGER_VERIFY_TOKEN"]
|
|
27
|
+
required_packages = ["requests"]
|
|
28
|
+
site_url = "https://www.messenger.com/"
|
|
29
|
+
description_text = "Facebook Messenger - Consumer instant messaging (Meta)"
|
|
30
|
+
|
|
31
|
+
def validate(self) -> tuple[bool, str]:
|
|
32
|
+
"""Validate that the recipient has a Messenger PSID"""
|
|
33
|
+
if not self.missive:
|
|
34
|
+
return False, "Missive not defined"
|
|
35
|
+
|
|
36
|
+
recipient = getattr(self.missive, "recipient", None)
|
|
37
|
+
if not recipient:
|
|
38
|
+
return False, "Recipient not defined"
|
|
39
|
+
|
|
40
|
+
metadata = getattr(recipient, "metadata", None) or {}
|
|
41
|
+
psid = metadata.get("messenger_psid")
|
|
42
|
+
if not psid:
|
|
43
|
+
return False, "Recipient does not have a Messenger PSID (add to metadata)"
|
|
44
|
+
|
|
45
|
+
return True, ""
|
|
46
|
+
|
|
47
|
+
def send_branded(self, brand_name: Optional[str] = None, **kwargs) -> bool:
|
|
48
|
+
"""
|
|
49
|
+
Send a message via Messenger Send API.
|
|
50
|
+
|
|
51
|
+
TODO: Implement actual sending via:
|
|
52
|
+
POST https://graph.facebook.com/v18.0/me/messages
|
|
53
|
+
"""
|
|
54
|
+
is_valid, error = self.validate()
|
|
55
|
+
if not is_valid:
|
|
56
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
# TODO: Implement actual sending
|
|
60
|
+
external_id = f"messenger_sim_{getattr(self.missive, 'id', 'unknown')}"
|
|
61
|
+
self._update_status(
|
|
62
|
+
MissiveStatus.SENT,
|
|
63
|
+
external_id=external_id,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def check_status(self, external_id: Optional[str] = None) -> Optional[str]:
|
|
69
|
+
"""Check status via Messenger webhooks"""
|
|
70
|
+
# TODO: Implement webhook handlers
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["MessengerProvider"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""In-app notification provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from ..status import MissiveStatus
|
|
9
|
+
from .base import BaseProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InAppNotificationProvider(BaseProvider):
|
|
13
|
+
"""In-app notification provider."""
|
|
14
|
+
|
|
15
|
+
name = "notification"
|
|
16
|
+
display_name = "Notification In-App"
|
|
17
|
+
supported_types = ["NOTIFICATION"]
|
|
18
|
+
services = ["notification"]
|
|
19
|
+
notification_geographic_coverage = ["*"]
|
|
20
|
+
notification_geo = notification_geographic_coverage
|
|
21
|
+
required_packages = []
|
|
22
|
+
description_text = "In-app notifications without external dependency"
|
|
23
|
+
|
|
24
|
+
def send_notification(self, **kwargs) -> bool:
|
|
25
|
+
"""Create an in-app notification"""
|
|
26
|
+
# Validation
|
|
27
|
+
is_valid, error = self.validate()
|
|
28
|
+
if not is_valid:
|
|
29
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
if not self._get_missive_value("recipient_user"):
|
|
33
|
+
self._update_status(MissiveStatus.FAILED, error_message="User missing")
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
# TODO: Create the notification
|
|
38
|
+
# Can use:
|
|
39
|
+
# - Django signals
|
|
40
|
+
# - WebSocket (channels)
|
|
41
|
+
# - Firebase Cloud Messaging
|
|
42
|
+
# - OneSignal
|
|
43
|
+
#
|
|
44
|
+
# Example with signal:
|
|
45
|
+
# from django.dispatch import Signal
|
|
46
|
+
# notification_created = Signal()
|
|
47
|
+
# notification_created.send(
|
|
48
|
+
# sender=self.__class__,
|
|
49
|
+
# missive=self.missive,
|
|
50
|
+
# recipient=self.missive.recipient_user,
|
|
51
|
+
# subject=self.missive.subject,
|
|
52
|
+
# body=self.missive.body
|
|
53
|
+
# )
|
|
54
|
+
|
|
55
|
+
# Notification is instantaneous
|
|
56
|
+
clock = getattr(self, "_clock", None)
|
|
57
|
+
now = clock() if callable(clock) else datetime.now(timezone.utc)
|
|
58
|
+
|
|
59
|
+
self._update_status(
|
|
60
|
+
MissiveStatus.SENT,
|
|
61
|
+
provider=self.name,
|
|
62
|
+
)
|
|
63
|
+
# Set sent_at and delivered_at to now for instant notifications
|
|
64
|
+
if self.missive and hasattr(self.missive, "sent_at"):
|
|
65
|
+
self.missive.sent_at = now
|
|
66
|
+
if self.missive and hasattr(self.missive, "delivered_at"):
|
|
67
|
+
self.missive.delivered_at = now
|
|
68
|
+
|
|
69
|
+
self._create_event("sent", "Notification created")
|
|
70
|
+
self._create_event("delivered", "Notification delivered")
|
|
71
|
+
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
self._update_status(MissiveStatus.FAILED, error_message=str(e))
|
|
76
|
+
self._create_event("failed", str(e))
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def validate_webhook_signature(
|
|
80
|
+
self,
|
|
81
|
+
payload: Any,
|
|
82
|
+
headers: Dict[str, str],
|
|
83
|
+
*,
|
|
84
|
+
missive_type: Optional[str] = None,
|
|
85
|
+
**kwargs: Any,
|
|
86
|
+
) -> Tuple[bool, str]:
|
|
87
|
+
"""No webhooks for in-app notifications"""
|
|
88
|
+
return False, "In-app notifications do not use webhooks"
|
|
89
|
+
|
|
90
|
+
def get_service_status(self) -> Dict:
|
|
91
|
+
"""
|
|
92
|
+
Gets in-app notification system status.
|
|
93
|
+
|
|
94
|
+
In-app notifications are managed locally, no limits.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Dict with status, availability, etc.
|
|
98
|
+
"""
|
|
99
|
+
return self._build_generic_service_status(
|
|
100
|
+
status="operational",
|
|
101
|
+
is_available=True,
|
|
102
|
+
credits_type="unlimited",
|
|
103
|
+
rate_limits={"per_second": None},
|
|
104
|
+
sla={"uptime_percentage": 100.0},
|
|
105
|
+
details={
|
|
106
|
+
"provider_type": "In-App (Local)",
|
|
107
|
+
"note": "Notifications stockées en base de données",
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = ["InAppNotificationProvider"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""SendGrid email provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Dict, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .base import BaseProvider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SendGridProvider(BaseProvider):
|
|
15
|
+
"""SendGrid email provider."""
|
|
16
|
+
|
|
17
|
+
name = "SendGrid"
|
|
18
|
+
display_name = "SendGrid"
|
|
19
|
+
supported_types = ["EMAIL", "EMAIL_MARKETING"]
|
|
20
|
+
# Geographic scope and pricing
|
|
21
|
+
email_geographic_coverage = ["*"]
|
|
22
|
+
email_geo = email_geographic_coverage
|
|
23
|
+
email_marketing_geographic_coverage = ["*"]
|
|
24
|
+
email_marketing_geo = email_marketing_geographic_coverage
|
|
25
|
+
email_price = 0.9 # Similar to Mailgun baseline
|
|
26
|
+
email_marketing_price = 0.15 # Marketing credits slightly higher
|
|
27
|
+
email_marketing_max_attachment_size_mb = 10
|
|
28
|
+
email_marketing_allowed_attachment_mime_types = [
|
|
29
|
+
"text/html",
|
|
30
|
+
"image/jpeg",
|
|
31
|
+
"image/png",
|
|
32
|
+
]
|
|
33
|
+
config_keys = ["SENDGRID_API_KEY"]
|
|
34
|
+
required_packages = ["sendgrid"]
|
|
35
|
+
site_url = "https://sendgrid.com/"
|
|
36
|
+
status_url = "https://status.sendgrid.com/"
|
|
37
|
+
documentation_url = "https://docs.sendgrid.com/"
|
|
38
|
+
description_text = "Transactional and marketing email (Twilio SendGrid)"
|
|
39
|
+
|
|
40
|
+
def send_email(self, **kwargs) -> bool:
|
|
41
|
+
"""Sends email via SendGrid API."""
|
|
42
|
+
return self._send_email_simulation(
|
|
43
|
+
prefix="sg", event_message="Email sent via SendGrid"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def send_email_marketing(self, **kwargs) -> bool:
|
|
47
|
+
"""Marketing campaigns follow the transactional send path."""
|
|
48
|
+
return self.send_email(**kwargs)
|
|
49
|
+
|
|
50
|
+
def validate_webhook_signature(
|
|
51
|
+
self,
|
|
52
|
+
payload: Any,
|
|
53
|
+
headers: Dict[str, str],
|
|
54
|
+
*,
|
|
55
|
+
missive_type: Optional[str] = None,
|
|
56
|
+
**kwargs: Any,
|
|
57
|
+
) -> Tuple[bool, str]:
|
|
58
|
+
"""Validate SendGrid webhook signature."""
|
|
59
|
+
webhook_key = self._config.get("SENDGRID_WEBHOOK_KEY")
|
|
60
|
+
if not webhook_key:
|
|
61
|
+
return True, "" # No validation
|
|
62
|
+
|
|
63
|
+
sig_header = "HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_SIGNATURE"
|
|
64
|
+
signature = headers.get(sig_header, "")
|
|
65
|
+
ts_header = "HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_TIMESTAMP"
|
|
66
|
+
timestamp = headers.get(ts_header, "")
|
|
67
|
+
|
|
68
|
+
if not signature or not timestamp:
|
|
69
|
+
return False, "Signature or timestamp missing"
|
|
70
|
+
|
|
71
|
+
# Reconstruct the signature
|
|
72
|
+
payload_str = json.dumps(payload, separators=(",", ":"))
|
|
73
|
+
signed_payload = timestamp + payload_str
|
|
74
|
+
|
|
75
|
+
expected_signature = base64.b64encode(
|
|
76
|
+
hmac.new(
|
|
77
|
+
webhook_key.encode(), signed_payload.encode(), hashlib.sha256
|
|
78
|
+
).digest()
|
|
79
|
+
).decode()
|
|
80
|
+
|
|
81
|
+
if hmac.compare_digest(signature, expected_signature):
|
|
82
|
+
return True, ""
|
|
83
|
+
return False, "Signature does not match"
|
|
84
|
+
|
|
85
|
+
def validate_email_marketing_webhook_signature(
|
|
86
|
+
self,
|
|
87
|
+
payload: Any,
|
|
88
|
+
headers: Dict[str, str],
|
|
89
|
+
*,
|
|
90
|
+
missive_type: Optional[str] = None,
|
|
91
|
+
**kwargs: Any,
|
|
92
|
+
) -> Tuple[bool, str]:
|
|
93
|
+
"""Marketing webhooks reuse the same verification."""
|
|
94
|
+
return self.validate_webhook_signature(
|
|
95
|
+
payload, headers, missive_type="EMAIL_MARKETING", **kwargs
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def extract_email_missive_id(self, payload: Any) -> Optional[str]:
|
|
99
|
+
"""Extract missive ID from SendGrid webhook."""
|
|
100
|
+
# SendGrid sends an array of events
|
|
101
|
+
if isinstance(payload, list) and len(payload) > 0:
|
|
102
|
+
event = payload[0]
|
|
103
|
+
if isinstance(event, dict):
|
|
104
|
+
result = event.get("missive_id") or event.get("custom_args", {}).get(
|
|
105
|
+
"missive_id"
|
|
106
|
+
)
|
|
107
|
+
return str(result) if result else None
|
|
108
|
+
elif isinstance(payload, dict):
|
|
109
|
+
result = payload.get("missive_id") or payload.get("custom_args", {}).get(
|
|
110
|
+
"missive_id"
|
|
111
|
+
)
|
|
112
|
+
return str(result) if result else None
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def extract_email_marketing_missive_id(self, payload: Any) -> Optional[str]:
|
|
116
|
+
"""Reuse transactional ID extraction."""
|
|
117
|
+
return self.extract_email_missive_id(payload)
|
|
118
|
+
|
|
119
|
+
def extract_event_type(self, payload: Any) -> str:
|
|
120
|
+
"""Extract event type from SendGrid webhook."""
|
|
121
|
+
if isinstance(payload, list) and len(payload) > 0:
|
|
122
|
+
event = payload[0]
|
|
123
|
+
if isinstance(event, dict):
|
|
124
|
+
result = event.get("event", "unknown")
|
|
125
|
+
return str(result) if result else "unknown"
|
|
126
|
+
elif isinstance(payload, dict):
|
|
127
|
+
result = payload.get("event", "unknown")
|
|
128
|
+
return str(result) if result else "unknown"
|
|
129
|
+
return "unknown"
|
|
130
|
+
|
|
131
|
+
def handle_email_marketing_webhook(
|
|
132
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
|
133
|
+
) -> Tuple[bool, str, Optional[Any]]:
|
|
134
|
+
"""Delegate marketing webhook handling to transactional handler."""
|
|
135
|
+
return self.handle_email_webhook(payload, headers)
|
|
136
|
+
|
|
137
|
+
def get_service_status(self) -> Dict:
|
|
138
|
+
"""
|
|
139
|
+
Gets SendGrid status and credits.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Dict with status, credits, etc.
|
|
143
|
+
"""
|
|
144
|
+
return self._build_generic_service_status(
|
|
145
|
+
credits_type="emails",
|
|
146
|
+
credits_currency="emails",
|
|
147
|
+
rate_limits={"per_second": 10, "per_minute": 600},
|
|
148
|
+
warnings=["SendGrid API not implemented - uncomment the code"],
|
|
149
|
+
details={
|
|
150
|
+
"status_page": "https://status.sendgrid.com/",
|
|
151
|
+
"api_docs": (
|
|
152
|
+
"https://docs.sendgrid.com/api-reference/"
|
|
153
|
+
"stats/retrieve-email-statistics"
|
|
154
|
+
),
|
|
155
|
+
},
|
|
156
|
+
sla={"uptime_percentage": 99.99, "response_time_ms": 100},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
__all__ = ["SendGridProvider"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Amazon SES email provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict
|
|
6
|
+
|
|
7
|
+
from ..status import MissiveStatus
|
|
8
|
+
from .base import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SESProvider(BaseProvider):
|
|
12
|
+
"""
|
|
13
|
+
Amazon SES (Simple Email Service) provider.
|
|
14
|
+
|
|
15
|
+
Required configuration:
|
|
16
|
+
AWS_ACCESS_KEY_ID: AWS access key
|
|
17
|
+
AWS_SECRET_ACCESS_KEY: AWS secret key
|
|
18
|
+
AWS_REGION: AWS region (e.g., eu-west-1, us-east-1)
|
|
19
|
+
SES_FROM_EMAIL: Verified sender email in SES
|
|
20
|
+
|
|
21
|
+
Supports:
|
|
22
|
+
- Transactional email
|
|
23
|
+
- Email marketing (with SES v2)
|
|
24
|
+
- Reputation management
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name = "ses"
|
|
28
|
+
display_name = "Amazon SES"
|
|
29
|
+
supported_types = ["EMAIL"]
|
|
30
|
+
services = ["email", "email_transactional", "email_marketing"]
|
|
31
|
+
config_keys = [
|
|
32
|
+
"AWS_ACCESS_KEY_ID",
|
|
33
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
34
|
+
"AWS_REGION",
|
|
35
|
+
"SES_FROM_EMAIL",
|
|
36
|
+
]
|
|
37
|
+
required_packages = ["boto3"]
|
|
38
|
+
site_url = "https://aws.amazon.com/ses/"
|
|
39
|
+
status_url = "https://health.aws.amazon.com/health/status"
|
|
40
|
+
documentation_url = "https://docs.aws.amazon.com/ses/"
|
|
41
|
+
description_text = "Amazon Simple Email Service - AWS transactional email"
|
|
42
|
+
|
|
43
|
+
def send_email(self, **kwargs) -> bool:
|
|
44
|
+
"""Send an email via Amazon SES"""
|
|
45
|
+
# Validation
|
|
46
|
+
is_valid, error = self.validate()
|
|
47
|
+
if not is_valid:
|
|
48
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
if not self._get_missive_value("recipient_email"):
|
|
52
|
+
self._update_status(MissiveStatus.FAILED, error_message="Email missing")
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
# Configuration AWS
|
|
57
|
+
aws_access_key = self._config.get("AWS_ACCESS_KEY_ID")
|
|
58
|
+
aws_secret_key = self._config.get("AWS_SECRET_ACCESS_KEY")
|
|
59
|
+
self._config.get("AWS_REGION", "eu-west-1")
|
|
60
|
+
from_email = self._config.get("SES_FROM_EMAIL")
|
|
61
|
+
|
|
62
|
+
if not all([aws_access_key, aws_secret_key, from_email]):
|
|
63
|
+
self._update_status(
|
|
64
|
+
MissiveStatus.FAILED,
|
|
65
|
+
error_message="Incomplete AWS SES configuration",
|
|
66
|
+
)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# TODO: Implement actual SES sending
|
|
70
|
+
# import boto3
|
|
71
|
+
# from botocore.exceptions import ClientError
|
|
72
|
+
#
|
|
73
|
+
# client = boto3.client(
|
|
74
|
+
# "ses",
|
|
75
|
+
# aws_access_key_id=aws_access_key,
|
|
76
|
+
# aws_secret_access_key=aws_secret_key,
|
|
77
|
+
# region_name=aws_region,
|
|
78
|
+
# )
|
|
79
|
+
#
|
|
80
|
+
# destination = {"ToAddresses": [self.missive.recipient_email]}
|
|
81
|
+
# message = {
|
|
82
|
+
# "Subject": {"Data": self.missive.subject, "Charset": "UTF-8"},
|
|
83
|
+
# "Body": {},
|
|
84
|
+
# }
|
|
85
|
+
#
|
|
86
|
+
# if self.missive.body_html:
|
|
87
|
+
# message["Body"]["Html"] = {
|
|
88
|
+
# "Data": self.missive.body_html,
|
|
89
|
+
# "Charset": "UTF-8",
|
|
90
|
+
# }
|
|
91
|
+
# if self.missive.body_text or self.missive.body:
|
|
92
|
+
# message["Body"]["Text"] = {
|
|
93
|
+
# "Data": self.missive.body_text or self.missive.body,
|
|
94
|
+
# "Charset": "UTF-8",
|
|
95
|
+
# }
|
|
96
|
+
#
|
|
97
|
+
# response = client.send_email(
|
|
98
|
+
# Source=from_email,
|
|
99
|
+
# Destination=destination,
|
|
100
|
+
# Message=message,
|
|
101
|
+
# )
|
|
102
|
+
#
|
|
103
|
+
# message_id = response.get("MessageId")
|
|
104
|
+
|
|
105
|
+
# Simulation
|
|
106
|
+
message_id = f"ses_{getattr(self.missive, 'id', 'unknown')}"
|
|
107
|
+
|
|
108
|
+
self._update_status(
|
|
109
|
+
MissiveStatus.SENT,
|
|
110
|
+
provider=self.name,
|
|
111
|
+
external_id=message_id,
|
|
112
|
+
)
|
|
113
|
+
self._create_event("sent", f"Email sent via Amazon SES (ID: {message_id})")
|
|
114
|
+
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
except Exception as e:
|
|
118
|
+
self._update_status(MissiveStatus.FAILED, error_message=str(e))
|
|
119
|
+
self._create_event("failed", str(e))
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def get_email_service_info(self) -> Dict:
|
|
123
|
+
"""
|
|
124
|
+
Gets Amazon SES service information.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Dict with quotas, credits, reputation, etc.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
aws_access_key = self._config.get("AWS_ACCESS_KEY_ID")
|
|
131
|
+
aws_secret_key = self._config.get("AWS_SECRET_ACCESS_KEY")
|
|
132
|
+
aws_region = self._config.get("AWS_REGION", "eu-west-1")
|
|
133
|
+
|
|
134
|
+
if not all([aws_access_key, aws_secret_key]):
|
|
135
|
+
return {
|
|
136
|
+
"credits": None,
|
|
137
|
+
"credits_type": "quota",
|
|
138
|
+
"is_available": False,
|
|
139
|
+
"limits": {},
|
|
140
|
+
"warnings": ["Incomplete AWS configuration"],
|
|
141
|
+
"reputation": {},
|
|
142
|
+
"details": {},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# TODO: Implement actual SES API calls
|
|
146
|
+
# import boto3
|
|
147
|
+
# from botocore.exceptions import ClientError
|
|
148
|
+
#
|
|
149
|
+
# client = boto3.client(
|
|
150
|
+
# "ses",
|
|
151
|
+
# aws_access_key_id=aws_access_key,
|
|
152
|
+
# aws_secret_access_key=aws_secret_key,
|
|
153
|
+
# region_name=aws_region,
|
|
154
|
+
# )
|
|
155
|
+
#
|
|
156
|
+
# quota = client.get_send_quota()
|
|
157
|
+
# max_24h = int(quota.get("Max24HourSend", 0))
|
|
158
|
+
# sent_last_24h = int(quota.get("SentLast24Hours", 0))
|
|
159
|
+
# remaining = max_24h - sent_last_24h
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"credits": None,
|
|
163
|
+
"credits_type": "quota",
|
|
164
|
+
"is_available": None,
|
|
165
|
+
"limits": {},
|
|
166
|
+
"warnings": ["SES API not implemented - uncomment the code"],
|
|
167
|
+
"reputation": {},
|
|
168
|
+
"details": {
|
|
169
|
+
"region": aws_region,
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
except Exception as e:
|
|
174
|
+
return {
|
|
175
|
+
"credits": None,
|
|
176
|
+
"credits_type": "quota",
|
|
177
|
+
"is_available": False,
|
|
178
|
+
"limits": {},
|
|
179
|
+
"warnings": [f"Error: {str(e)}"],
|
|
180
|
+
"reputation": {},
|
|
181
|
+
"details": {},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = ["SESProvider"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Signal Messenger provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..status import MissiveStatus
|
|
8
|
+
from .base import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SignalProvider(BaseProvider):
|
|
12
|
+
"""
|
|
13
|
+
Signal Messenger provider.
|
|
14
|
+
|
|
15
|
+
Required configuration:
|
|
16
|
+
SIGNAL_CLI_REST_API_URL: signal-cli-rest-api URL
|
|
17
|
+
SIGNAL_SENDER_NUMBER: Registered sender number
|
|
18
|
+
|
|
19
|
+
Recipient must have a mobile phone number.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name = "signal"
|
|
23
|
+
display_name = "Signal"
|
|
24
|
+
supported_types = ["BRANDED"]
|
|
25
|
+
brands = ["signal"] # Signal only
|
|
26
|
+
config_keys = ["SIGNAL_API_KEY"]
|
|
27
|
+
required_packages = ["requests"]
|
|
28
|
+
site_url = "https://signal.org/"
|
|
29
|
+
description_text = "Secure end-to-end encrypted messaging"
|
|
30
|
+
|
|
31
|
+
def validate(self) -> tuple[bool, str]:
|
|
32
|
+
"""Validate that the recipient has a mobile number"""
|
|
33
|
+
if not self.missive:
|
|
34
|
+
return False, "Missive not defined"
|
|
35
|
+
|
|
36
|
+
recipient = getattr(self.missive, "recipient", None)
|
|
37
|
+
if not recipient or not getattr(recipient, "mobile", None):
|
|
38
|
+
return False, "Recipient must have a mobile number for Signal"
|
|
39
|
+
|
|
40
|
+
return True, ""
|
|
41
|
+
|
|
42
|
+
def send_branded(self, brand_name: Optional[str] = None, **kwargs) -> bool:
|
|
43
|
+
"""
|
|
44
|
+
Send a message via Signal.
|
|
45
|
+
|
|
46
|
+
TODO: Implement actual sending via signal-cli-rest-api
|
|
47
|
+
"""
|
|
48
|
+
is_valid, error = self.validate()
|
|
49
|
+
if not is_valid:
|
|
50
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
# TODO: Implement actual sending
|
|
54
|
+
external_id = f"signal_sim_{getattr(self.missive, 'id', 'unknown')}"
|
|
55
|
+
self._update_status(
|
|
56
|
+
MissiveStatus.SENT,
|
|
57
|
+
external_id=external_id,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def check_status(self, external_id: Optional[str] = None) -> Optional[str]:
|
|
63
|
+
"""Check status of a Signal message"""
|
|
64
|
+
# TODO: Implement if needed
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["SignalProvider"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Slack provider for channel and direct messaging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..status import MissiveStatus
|
|
8
|
+
from .base import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SlackProvider(BaseProvider):
|
|
12
|
+
"""Slack provider."""
|
|
13
|
+
|
|
14
|
+
name = "slack"
|
|
15
|
+
display_name = "Slack"
|
|
16
|
+
supported_types = ["BRANDED"]
|
|
17
|
+
services = ["slack", "messaging"]
|
|
18
|
+
brands = ["slack"]
|
|
19
|
+
config_keys = ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"]
|
|
20
|
+
required_packages = ["slack-sdk"]
|
|
21
|
+
site_url = "https://slack.com/"
|
|
22
|
+
status_url = "https://status.slack.com/"
|
|
23
|
+
documentation_url = "https://api.slack.com/"
|
|
24
|
+
description_text = "Professional team collaboration messaging"
|
|
25
|
+
# Geographic scope
|
|
26
|
+
branded_geo = "*"
|
|
27
|
+
|
|
28
|
+
def validate(self) -> tuple[bool, str]:
|
|
29
|
+
"""Validates recipient has Slack user_id or channel_id."""
|
|
30
|
+
if not self.missive:
|
|
31
|
+
return False, "Missive not defined"
|
|
32
|
+
|
|
33
|
+
recipient = getattr(self.missive, "recipient", None)
|
|
34
|
+
if not recipient:
|
|
35
|
+
return False, "Recipient not defined"
|
|
36
|
+
|
|
37
|
+
metadata = getattr(recipient, "metadata", None) or {}
|
|
38
|
+
user_id = metadata.get("slack_user_id")
|
|
39
|
+
channel_id = metadata.get("slack_channel_id")
|
|
40
|
+
|
|
41
|
+
if not user_id and not channel_id:
|
|
42
|
+
return (
|
|
43
|
+
False,
|
|
44
|
+
"Recipient must have slack_user_id or slack_channel_id in metadata",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return True, ""
|
|
48
|
+
|
|
49
|
+
def send_slack(self) -> bool:
|
|
50
|
+
"""Sends Slack message via Web API."""
|
|
51
|
+
is_valid, error = self.validate()
|
|
52
|
+
if not is_valid:
|
|
53
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
# TODO: Implement actual sending
|
|
57
|
+
# from slack_sdk import WebClient
|
|
58
|
+
#
|
|
59
|
+
# client = WebClient(token=self._config.get("SLACK_BOT_TOKEN"))
|
|
60
|
+
# response = client.chat_postMessage(
|
|
61
|
+
# channel=channel_id or user_id,
|
|
62
|
+
# text=self.missive.body_text,
|
|
63
|
+
# blocks=[...] # For rich formatting
|
|
64
|
+
# )
|
|
65
|
+
|
|
66
|
+
external_id = f"slack_sim_{getattr(self.missive, 'id', 'unknown')}"
|
|
67
|
+
self._update_status(
|
|
68
|
+
MissiveStatus.SENT,
|
|
69
|
+
external_id=external_id,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
def check_status(self, external_id: Optional[str] = None) -> Optional[str]:
|
|
75
|
+
"""Checks if message was read (requires Events API)."""
|
|
76
|
+
# TODO: Implement via Slack Events API
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["SlackProvider"]
|