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,26 @@
|
|
|
1
|
+
"""Shared constants for postal providers."""
|
|
2
|
+
|
|
3
|
+
POSTAL_DEFAULT_MIME_TYPES = [
|
|
4
|
+
"application/pdf",
|
|
5
|
+
"application/msword",
|
|
6
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
7
|
+
"application/rtf",
|
|
8
|
+
"text/plain",
|
|
9
|
+
"application/vnd.ms-excel",
|
|
10
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
POSTAL_ENVELOPE_LIMITS = [
|
|
14
|
+
{
|
|
15
|
+
"format": "C4 double-window",
|
|
16
|
+
"dimensions_mm": "210x300",
|
|
17
|
+
"max_sheets": 45,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"format": "DL simple/double-window",
|
|
21
|
+
"dimensions_mm": "114x229",
|
|
22
|
+
"max_sheets": 5,
|
|
23
|
+
},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
__all__ = ["POSTAL_DEFAULT_MIME_TYPES", "POSTAL_ENVELOPE_LIMITS"]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""SMS provider mixin without Django dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from ...status import MissiveStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseSMSMixin:
|
|
12
|
+
"""SMS-specific functionality mixin."""
|
|
13
|
+
|
|
14
|
+
sms_price: float = 0.50
|
|
15
|
+
sms_archiving_duration: int = 0 # Days SMS logs stay accessible
|
|
16
|
+
sms_geographic_coverage: list[str] | str = ["*"]
|
|
17
|
+
sms_geo = sms_geographic_coverage
|
|
18
|
+
sms_character_limit: int = 160
|
|
19
|
+
sms_unicode_character_limit: int = 70
|
|
20
|
+
sms_config_fields: list[str] = [
|
|
21
|
+
"sms_price",
|
|
22
|
+
"sms_archiving_duration",
|
|
23
|
+
"sms_character_limit",
|
|
24
|
+
"sms_unicode_character_limit",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def get_sms_service_info(self) -> Dict[str, Any]:
|
|
28
|
+
"""Return SMS service information. Override in subclasses."""
|
|
29
|
+
return {
|
|
30
|
+
"credits": None,
|
|
31
|
+
"credits_type": "count",
|
|
32
|
+
"is_available": None,
|
|
33
|
+
"limits": {
|
|
34
|
+
"archiving_duration_days": self.sms_archiving_duration,
|
|
35
|
+
},
|
|
36
|
+
"warnings": [
|
|
37
|
+
"get_sms_service_info() method not implemented for this provider"
|
|
38
|
+
],
|
|
39
|
+
"details": {
|
|
40
|
+
"geographic_coverage": self.sms_geographic_coverage,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def check_sms_delivery_status(self, **kwargs) -> Dict[str, Any]:
|
|
45
|
+
"""Check SMS delivery status. Override in subclasses."""
|
|
46
|
+
return {
|
|
47
|
+
"status": "unknown",
|
|
48
|
+
"delivered_at": None,
|
|
49
|
+
"error_code": None,
|
|
50
|
+
"error_message": "check_sms_delivery_status() method not implemented for this provider",
|
|
51
|
+
"details": {},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def send_sms(self, **kwargs) -> bool:
|
|
55
|
+
"""Send SMS. Override in subclasses."""
|
|
56
|
+
recipient_phone = self._get_missive_value("get_recipient_phone")
|
|
57
|
+
if not recipient_phone:
|
|
58
|
+
recipient_phone = self._get_missive_value("recipient_phone")
|
|
59
|
+
|
|
60
|
+
if not recipient_phone:
|
|
61
|
+
self._update_status(MissiveStatus.FAILED, error_message="No phone number")
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
raise NotImplementedError(f"{self.name} must implement the send_sms() method")
|
|
65
|
+
|
|
66
|
+
def calculate_sms_delivery_risk(
|
|
67
|
+
self, missive: Optional[Any] = None
|
|
68
|
+
) -> Dict[str, Any]:
|
|
69
|
+
"""Calculate delivery risk for SMS missives."""
|
|
70
|
+
|
|
71
|
+
def _handler(
|
|
72
|
+
_target: Any,
|
|
73
|
+
factors: Dict[str, Any],
|
|
74
|
+
recommendations: List[str],
|
|
75
|
+
total_risk: float,
|
|
76
|
+
) -> Dict[str, Any]:
|
|
77
|
+
phone = self._get_missive_value("get_recipient_phone")
|
|
78
|
+
if not phone:
|
|
79
|
+
phone = self._get_missive_value("recipient_phone")
|
|
80
|
+
|
|
81
|
+
phone_validation: Optional[Dict[str, Any]] = None
|
|
82
|
+
risk_total = total_risk
|
|
83
|
+
|
|
84
|
+
if not phone:
|
|
85
|
+
recommendations.append("Recipient phone missing")
|
|
86
|
+
risk_total = 100.0
|
|
87
|
+
else:
|
|
88
|
+
phone_str = str(phone)
|
|
89
|
+
phone_validation = self.validate_phone_number(phone_str)
|
|
90
|
+
factors["phone_validation"] = phone_validation
|
|
91
|
+
risk_total += phone_validation.get("risk_score", 0)
|
|
92
|
+
recommendations.extend(phone_validation.get("warnings", []))
|
|
93
|
+
|
|
94
|
+
if not phone_validation.get("is_valid", True):
|
|
95
|
+
risk_total = max(risk_total, 80)
|
|
96
|
+
|
|
97
|
+
risk_score = min(int(risk_total), 100)
|
|
98
|
+
risk_level = self._calculate_risk_level(risk_score)
|
|
99
|
+
|
|
100
|
+
phone_is_valid = (
|
|
101
|
+
phone_validation.get("is_valid", True) if phone_validation else False
|
|
102
|
+
)
|
|
103
|
+
should_send = risk_score < 70 and phone_is_valid
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
"risk_score": risk_score,
|
|
107
|
+
"risk_level": risk_level,
|
|
108
|
+
"factors": factors,
|
|
109
|
+
"recommendations": recommendations,
|
|
110
|
+
"should_send": should_send,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return self._run_risk_analysis(missive, _handler)
|
|
114
|
+
|
|
115
|
+
def validate_phone_number(
|
|
116
|
+
self, phone: str, country_code: str = "FR"
|
|
117
|
+
) -> Dict[str, Any]:
|
|
118
|
+
"""Validate a phone number and assess delivery risk."""
|
|
119
|
+
warnings = []
|
|
120
|
+
details: Dict[str, Any] = {}
|
|
121
|
+
|
|
122
|
+
cleaned = re.sub(r"[^\d+]", "", phone)
|
|
123
|
+
details["cleaned"] = cleaned
|
|
124
|
+
|
|
125
|
+
if not cleaned.startswith("+"):
|
|
126
|
+
warnings.append("International format recommended (+33...)")
|
|
127
|
+
|
|
128
|
+
risk_score = len(warnings) * 20
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"is_valid": len(cleaned) >= 10,
|
|
132
|
+
"is_mobile": None,
|
|
133
|
+
"formatted": cleaned,
|
|
134
|
+
"carrier": "",
|
|
135
|
+
"line_type": "unknown",
|
|
136
|
+
"risk_score": risk_score,
|
|
137
|
+
"warnings": warnings,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def calculate_sms_segments(self, message: str) -> Dict[str, Any]:
|
|
141
|
+
"""Calculate number of SMS segments and estimated cost."""
|
|
142
|
+
gsm7_chars = set(
|
|
143
|
+
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?"
|
|
144
|
+
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
is_gsm7 = all(c in gsm7_chars for c in message)
|
|
148
|
+
encoding = "GSM-7" if is_gsm7 else "Unicode"
|
|
149
|
+
|
|
150
|
+
if is_gsm7:
|
|
151
|
+
single_limit = self.sms_character_limit
|
|
152
|
+
multi_limit = max(self.sms_character_limit - 7, 1)
|
|
153
|
+
else:
|
|
154
|
+
single_limit = self.sms_unicode_character_limit
|
|
155
|
+
multi_limit = max(self.sms_unicode_character_limit - 3, 1)
|
|
156
|
+
|
|
157
|
+
length = len(message)
|
|
158
|
+
if length == 0:
|
|
159
|
+
segments = 0
|
|
160
|
+
elif length <= single_limit:
|
|
161
|
+
segments = 1
|
|
162
|
+
else:
|
|
163
|
+
segments = (length + multi_limit - 1) // multi_limit
|
|
164
|
+
|
|
165
|
+
cost_per_segment = self._config.get(
|
|
166
|
+
"SMS_COST_PER_SEGMENT", self.sms_price or 0.05
|
|
167
|
+
)
|
|
168
|
+
estimated_cost = segments * cost_per_segment
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"segments": segments,
|
|
172
|
+
"characters": length,
|
|
173
|
+
"encoding": encoding,
|
|
174
|
+
"estimated_cost": estimated_cost,
|
|
175
|
+
"per_segment_limit": single_limit if segments == 1 else multi_limit,
|
|
176
|
+
"is_multipart": segments > 1,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def format_phone_international(self, phone: str, country_code: str = "FR") -> str:
|
|
180
|
+
"""Format a phone number in international format."""
|
|
181
|
+
cleaned = re.sub(r"[^\d+]", "", phone)
|
|
182
|
+
|
|
183
|
+
if cleaned.startswith("+"):
|
|
184
|
+
return cleaned
|
|
185
|
+
|
|
186
|
+
if country_code == "FR" and cleaned.startswith("0"):
|
|
187
|
+
return "+33" + cleaned[1:]
|
|
188
|
+
|
|
189
|
+
return "+" + cleaned
|
|
190
|
+
|
|
191
|
+
def cancel_sms(self, **kwargs) -> bool:
|
|
192
|
+
"""Cancel a scheduled SMS (override in subclasses)."""
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def validate_sms_webhook_signature(
|
|
196
|
+
self, payload: Any, headers: Dict[str, str]
|
|
197
|
+
) -> Tuple[bool, str]:
|
|
198
|
+
"""Validate SMS webhook signature. Override in subclasses."""
|
|
199
|
+
return True, ""
|
|
200
|
+
|
|
201
|
+
def handle_sms_webhook(
|
|
202
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
|
203
|
+
) -> Tuple[bool, str, Optional[Any]]:
|
|
204
|
+
"""Process SMS webhook payload. Override in subclasses."""
|
|
205
|
+
return (
|
|
206
|
+
False,
|
|
207
|
+
"handle_sms_webhook() method not implemented for this provider",
|
|
208
|
+
None,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def extract_sms_missive_id(self, payload: Dict[str, Any]) -> Optional[str]:
|
|
212
|
+
"""Extract missive ID from SMS webhook payload. Override in subclasses."""
|
|
213
|
+
return None
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Voice call provider mixin without Django dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from ...status import MissiveStatus
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseVoiceCallMixin:
|
|
11
|
+
"""Voice call-specific functionality mixin."""
|
|
12
|
+
|
|
13
|
+
voice_call_archiving_duration: int = 0 # Days call logs stay downloadable
|
|
14
|
+
voice_call_geographic_coverage: list[str] | str = ["*"]
|
|
15
|
+
voice_call_geo = voice_call_geographic_coverage
|
|
16
|
+
|
|
17
|
+
def get_voice_call_service_info(self) -> Dict[str, Any]:
|
|
18
|
+
"""Return voice call service information. Override in subclasses."""
|
|
19
|
+
return {
|
|
20
|
+
"credits": None,
|
|
21
|
+
"credits_type": "time",
|
|
22
|
+
"is_available": None,
|
|
23
|
+
"limits": {
|
|
24
|
+
"archiving_duration_days": self.voice_call_archiving_duration,
|
|
25
|
+
},
|
|
26
|
+
"warnings": [
|
|
27
|
+
"get_voice_call_service_info() method not implemented for this provider"
|
|
28
|
+
],
|
|
29
|
+
"options": [],
|
|
30
|
+
"details": {
|
|
31
|
+
"geographic_coverage": self.voice_call_geographic_coverage,
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def check_voice_call_delivery_status(self, **kwargs) -> Dict[str, Any]:
|
|
36
|
+
"""Check voice call delivery status. Override in subclasses."""
|
|
37
|
+
return {
|
|
38
|
+
"status": "unknown",
|
|
39
|
+
"delivered_at": None,
|
|
40
|
+
"duration": None,
|
|
41
|
+
"error_code": None,
|
|
42
|
+
"error_message": "check_voice_call_delivery_status() method not implemented for this provider",
|
|
43
|
+
"details": {},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def send_voice_call(self, **kwargs) -> bool:
|
|
47
|
+
"""Send a voice call. Override in subclasses."""
|
|
48
|
+
recipient_phone = self._get_missive_value("get_recipient_phone")
|
|
49
|
+
if not recipient_phone:
|
|
50
|
+
recipient_phone = self._get_missive_value("recipient_phone")
|
|
51
|
+
|
|
52
|
+
if not recipient_phone:
|
|
53
|
+
self._update_status(MissiveStatus.FAILED, error_message="No phone number")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
raise NotImplementedError(
|
|
57
|
+
f"{self.name} must implement the send_voice_call() method"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def cancel_voice_call(self, **kwargs) -> bool:
|
|
61
|
+
"""Cancel a scheduled voice call (override in subclasses)."""
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def validate_voice_call_webhook_signature(
|
|
65
|
+
self, payload: Any, headers: Dict[str, str]
|
|
66
|
+
) -> Tuple[bool, str]:
|
|
67
|
+
"""Validate voice call webhook signature. Override in subclasses."""
|
|
68
|
+
return True, ""
|
|
69
|
+
|
|
70
|
+
def handle_voice_call_webhook(
|
|
71
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
|
72
|
+
) -> Tuple[bool, str, Optional[Any]]:
|
|
73
|
+
"""Process voice call webhook payload. Override in subclasses."""
|
|
74
|
+
return (
|
|
75
|
+
False,
|
|
76
|
+
"handle_voice_call_webhook() method not implemented for this provider",
|
|
77
|
+
None,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def extract_voice_call_missive_id(self, payload: Dict[str, Any]) -> Optional[str]:
|
|
81
|
+
"""Extract missive ID from voice call webhook payload. Override in subclasses."""
|
|
82
|
+
return None
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Brevo provider for email and SMS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from ..status import MissiveStatus
|
|
8
|
+
from .base import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BrevoProvider(BaseProvider):
|
|
12
|
+
"""
|
|
13
|
+
Brevo (ex Sendinblue) provider.
|
|
14
|
+
|
|
15
|
+
Supports:
|
|
16
|
+
- Email (transactional/marketing)
|
|
17
|
+
- SMS
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name = "Brevo"
|
|
21
|
+
display_name = "Brevo"
|
|
22
|
+
supported_types = ["EMAIL", "EMAIL_MARKETING", "SMS"]
|
|
23
|
+
config_keys = ["BREVO_API_KEY", "BREVO_SMS_SENDER", "BREVO_DEFAULT_FROM_EMAIL"]
|
|
24
|
+
required_packages = ["sib-api-v3-sdk"]
|
|
25
|
+
site_url = "https://www.brevo.com/"
|
|
26
|
+
status_url = "https://status.brevo.com/"
|
|
27
|
+
documentation_url = "https://developers.brevo.com/"
|
|
28
|
+
description_text = "Complete CRM platform (Email, SMS, Marketing automation)"
|
|
29
|
+
# Geographic scopes
|
|
30
|
+
email_geographic_coverage = ["*"]
|
|
31
|
+
email_geo = email_geographic_coverage
|
|
32
|
+
email_marketing_geographic_coverage = ["*"]
|
|
33
|
+
email_marketing_geo = email_marketing_geographic_coverage
|
|
34
|
+
sms_geographic_coverage = ["*"]
|
|
35
|
+
sms_geo = sms_geographic_coverage
|
|
36
|
+
# Pricing and limits
|
|
37
|
+
email_price = 0.08 # transactional email unit cost (default Brevo Essentials)
|
|
38
|
+
email_marketing_price = 0.05 # cost attributed to marketing sends
|
|
39
|
+
email_marketing_max_attachment_size_mb = 10 # lighter assets for campaigns
|
|
40
|
+
email_marketing_allowed_attachment_mime_types = [
|
|
41
|
+
"text/html",
|
|
42
|
+
"image/jpeg",
|
|
43
|
+
"image/png",
|
|
44
|
+
]
|
|
45
|
+
sms_price = 0.07 # average SMS HT within Europe zone
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# Common helpers
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def _get_sender_email(self) -> Optional[str]:
|
|
52
|
+
return self._config.get("BREVO_DEFAULT_FROM_EMAIL")
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Email
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def send_email(self, **kwargs) -> bool:
|
|
59
|
+
"""Simulate email sending via Brevo."""
|
|
60
|
+
risk = self.calculate_email_delivery_risk()
|
|
61
|
+
if not risk.get("should_send", True):
|
|
62
|
+
recommendations = risk.get("recommendations", [])
|
|
63
|
+
error_message = next(
|
|
64
|
+
(rec for rec in recommendations if rec), "Email delivery blocked"
|
|
65
|
+
)
|
|
66
|
+
self._update_status(MissiveStatus.FAILED, error_message=error_message)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
external_id = f"brevo_email_{getattr(self.missive, 'id', 'unknown')}"
|
|
70
|
+
self._update_status(
|
|
71
|
+
MissiveStatus.SENT, provider=self.name, external_id=external_id
|
|
72
|
+
)
|
|
73
|
+
self._create_event("sent", "Email sent via Brevo")
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
def send_email_marketing(self, **kwargs) -> bool:
|
|
77
|
+
"""Reuse transactional pipeline for marketing campaigns."""
|
|
78
|
+
risk = self.calculate_email_marketing_delivery_risk()
|
|
79
|
+
if not risk.get("should_send", True):
|
|
80
|
+
recommendations = risk.get("recommendations", [])
|
|
81
|
+
error_message = next(
|
|
82
|
+
(rec for rec in recommendations if rec), "Email marketing blocked"
|
|
83
|
+
)
|
|
84
|
+
self._update_status(MissiveStatus.FAILED, error_message=error_message)
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
external_id = f"brevo_email_marketing_{getattr(self.missive, 'id', 'unknown')}"
|
|
88
|
+
self._update_status(
|
|
89
|
+
MissiveStatus.SENT, provider=self.name, external_id=external_id
|
|
90
|
+
)
|
|
91
|
+
self._create_event("sent", "Email marketing campaign sent via Brevo")
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
def get_email_service_info(self) -> Dict[str, Any]:
|
|
95
|
+
base = super().get_email_service_info()
|
|
96
|
+
base.update(
|
|
97
|
+
{
|
|
98
|
+
"service": "brevo_email",
|
|
99
|
+
"warnings": base.get("warnings", []),
|
|
100
|
+
"details": {"supports_marketing": True},
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
return base
|
|
104
|
+
|
|
105
|
+
def get_email_marketing_service_info(self) -> Dict[str, Any]:
|
|
106
|
+
base = super().get_email_marketing_service_info()
|
|
107
|
+
base.update(
|
|
108
|
+
{
|
|
109
|
+
"service": "brevo_email_marketing",
|
|
110
|
+
"warnings": base.get("warnings", []),
|
|
111
|
+
"details": {
|
|
112
|
+
"supports_marketing": True,
|
|
113
|
+
"geographic_coverage": self.email_marketing_geographic_coverage,
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
return base
|
|
118
|
+
|
|
119
|
+
def check_email_marketing_delivery_status(self, **kwargs) -> Dict[str, Any]:
|
|
120
|
+
return self.check_email_delivery_status(**kwargs)
|
|
121
|
+
|
|
122
|
+
def cancel_email_marketing(self, **kwargs) -> bool:
|
|
123
|
+
return self.cancel_email(**kwargs)
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# SMS
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def send_sms(self, **kwargs) -> bool:
|
|
130
|
+
"""Simulate SMS sending via Brevo."""
|
|
131
|
+
risk = self.calculate_sms_delivery_risk()
|
|
132
|
+
if not risk.get("should_send", True):
|
|
133
|
+
recommendations = risk.get("recommendations", [])
|
|
134
|
+
error_message = next(
|
|
135
|
+
(rec for rec in recommendations if rec), "SMS delivery blocked"
|
|
136
|
+
)
|
|
137
|
+
self._update_status(MissiveStatus.FAILED, error_message=error_message)
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
external_id = f"brevo_sms_{getattr(self.missive, 'id', 'unknown')}"
|
|
141
|
+
self._update_status(
|
|
142
|
+
MissiveStatus.SENT, provider=self.name, external_id=external_id
|
|
143
|
+
)
|
|
144
|
+
self._create_event("sent", "SMS sent via Brevo")
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
def get_sms_service_info(self) -> Dict[str, Any]:
|
|
148
|
+
base = super().get_sms_service_info()
|
|
149
|
+
base.update(
|
|
150
|
+
{
|
|
151
|
+
"service": "brevo_sms",
|
|
152
|
+
"warnings": base.get("warnings", []),
|
|
153
|
+
"details": {"supports_unicode": True},
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
return base
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
# Webhooks / Monitoring (placeholders)
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def validate_webhook_signature(
|
|
163
|
+
self,
|
|
164
|
+
payload: Any,
|
|
165
|
+
headers: Dict[str, str],
|
|
166
|
+
*,
|
|
167
|
+
missive_type: Optional[str] = None,
|
|
168
|
+
**kwargs: Any,
|
|
169
|
+
) -> Tuple[bool, str]:
|
|
170
|
+
"""Validate Brevo webhook signature (not implemented)."""
|
|
171
|
+
return True, ""
|
|
172
|
+
|
|
173
|
+
def extract_email_missive_id(self, payload: Any) -> Optional[str]:
|
|
174
|
+
"""Extract missive ID from Brevo email webhook payload."""
|
|
175
|
+
if isinstance(payload, dict):
|
|
176
|
+
tag = payload.get("tag", "")
|
|
177
|
+
if isinstance(tag, str) and tag.startswith("missive_"):
|
|
178
|
+
return tag.replace("missive_", "")
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def validate_email_marketing_webhook_signature(
|
|
182
|
+
self, payload: Any, headers: Dict[str, str]
|
|
183
|
+
) -> Tuple[bool, str]:
|
|
184
|
+
"""Marketing emails share the same signature scheme."""
|
|
185
|
+
return self.validate_webhook_signature(
|
|
186
|
+
payload, headers, missive_type="EMAIL_MARKETING"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def handle_email_marketing_webhook(
|
|
190
|
+
self, payload: Dict[str, Any], headers: Dict[str, str]
|
|
191
|
+
) -> Tuple[bool, str, Optional[Any]]:
|
|
192
|
+
"""Delegate to transactional handler while keeping service naming."""
|
|
193
|
+
success, message, data = self.handle_email_webhook(payload, headers)
|
|
194
|
+
if not success and "email_marketing" not in message.lower():
|
|
195
|
+
message = f"[marketing] {message}"
|
|
196
|
+
return success, message, data
|
|
197
|
+
|
|
198
|
+
def extract_email_marketing_missive_id(self, payload: Any) -> Optional[str]:
|
|
199
|
+
"""Reuse email missive ID extraction logic."""
|
|
200
|
+
return self.extract_email_missive_id(payload)
|
|
201
|
+
|
|
202
|
+
def extract_sms_missive_id(self, payload: Any) -> Optional[str]:
|
|
203
|
+
"""Extract missive ID from Brevo SMS webhook payload."""
|
|
204
|
+
if isinstance(payload, dict):
|
|
205
|
+
tag = payload.get("tag", "")
|
|
206
|
+
if isinstance(tag, str) and tag.startswith("missive_"):
|
|
207
|
+
return tag.replace("missive_", "")
|
|
208
|
+
return None # type: ignore[no-any-return]
|
|
209
|
+
|
|
210
|
+
def extract_event_type(self, payload: Any) -> str:
|
|
211
|
+
"""Return Brevo event type from webhook payload."""
|
|
212
|
+
if isinstance(payload, dict):
|
|
213
|
+
result = payload.get("event", "unknown")
|
|
214
|
+
return str(result) if result else "unknown" # type: ignore[no-any-return]
|
|
215
|
+
return "unknown"
|
|
216
|
+
|
|
217
|
+
def get_service_status(self) -> Dict[str, Any]:
|
|
218
|
+
"""Return simulated service status/credits."""
|
|
219
|
+
clock = getattr(self, "_clock", None)
|
|
220
|
+
last_check = clock() if callable(clock) else None
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"status": "unknown",
|
|
224
|
+
"is_available": None,
|
|
225
|
+
"services": self._get_services(),
|
|
226
|
+
"credits": {
|
|
227
|
+
"type": "mixed",
|
|
228
|
+
"email": {
|
|
229
|
+
"remaining": None,
|
|
230
|
+
"limit": "unknown",
|
|
231
|
+
},
|
|
232
|
+
"sms": {
|
|
233
|
+
"remaining": None,
|
|
234
|
+
"currency": "sms_units",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
"rate_limits": {"per_second": 10},
|
|
238
|
+
"sla": {"uptime_percentage": 99.95},
|
|
239
|
+
"last_check": last_check,
|
|
240
|
+
"warnings": ["Brevo API integration not implemented."],
|
|
241
|
+
"details": {
|
|
242
|
+
"status_page": "https://status.brevo.com/",
|
|
243
|
+
"api_docs": "https://developers.brevo.com/",
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
# Risk calculations
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def calculate_email_delivery_risk(
|
|
252
|
+
self, missive: Optional[Any] = None
|
|
253
|
+
) -> Dict[str, Any]:
|
|
254
|
+
"""Assess whether an email can be sent safely via Brevo."""
|
|
255
|
+
|
|
256
|
+
def _handler(
|
|
257
|
+
_target: Any,
|
|
258
|
+
factors: Dict[str, Any],
|
|
259
|
+
recommendations: List[str],
|
|
260
|
+
total_risk: float,
|
|
261
|
+
) -> Dict[str, Any]:
|
|
262
|
+
risk_total = total_risk
|
|
263
|
+
if "BREVO_API_KEY" not in self._config:
|
|
264
|
+
recommendations.append("Missing BREVO_API_KEY in configuration")
|
|
265
|
+
risk_total = 100.0
|
|
266
|
+
|
|
267
|
+
recipient_email = self._get_missive_value("recipient_email")
|
|
268
|
+
if not recipient_email:
|
|
269
|
+
recommendations.append("Recipient email missing")
|
|
270
|
+
risk_total = 100.0
|
|
271
|
+
else:
|
|
272
|
+
email_validation = self.validate_email(recipient_email)
|
|
273
|
+
factors["email_validation"] = email_validation
|
|
274
|
+
risk_total += email_validation.get("risk_score", 0) * 0.5
|
|
275
|
+
recommendations.extend(email_validation.get("warnings", []))
|
|
276
|
+
|
|
277
|
+
sender_email = self._get_sender_email()
|
|
278
|
+
if not sender_email:
|
|
279
|
+
recommendations.append("BREVO_DEFAULT_FROM_EMAIL missing")
|
|
280
|
+
risk_total = max(risk_total, 80)
|
|
281
|
+
|
|
282
|
+
service_status = self.get_service_status()
|
|
283
|
+
factors["service_status"] = service_status
|
|
284
|
+
if service_status.get("is_available") is False:
|
|
285
|
+
risk_total += 40
|
|
286
|
+
recommendations.append("Brevo email service currently unavailable")
|
|
287
|
+
|
|
288
|
+
risk_score = min(int(risk_total), 100)
|
|
289
|
+
risk_level = self._calculate_risk_level(risk_score)
|
|
290
|
+
|
|
291
|
+
should_send = (
|
|
292
|
+
risk_score < 70 and "Recipient email missing" not in recommendations
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
"risk_score": risk_score,
|
|
297
|
+
"risk_level": risk_level,
|
|
298
|
+
"factors": factors,
|
|
299
|
+
"recommendations": recommendations,
|
|
300
|
+
"should_send": should_send,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return self._run_risk_analysis(missive, _handler)
|
|
304
|
+
|
|
305
|
+
def calculate_email_marketing_delivery_risk(
|
|
306
|
+
self, missive: Optional[Any] = None
|
|
307
|
+
) -> Dict[str, Any]:
|
|
308
|
+
"""Marketing risk leverages the transactional heuristics."""
|
|
309
|
+
return self.calculate_email_delivery_risk(missive)
|
|
310
|
+
|
|
311
|
+
def calculate_sms_delivery_risk(
|
|
312
|
+
self, missive: Optional[Any] = None
|
|
313
|
+
) -> Dict[str, Any]:
|
|
314
|
+
"""Assess whether an SMS can be sent safely via Brevo."""
|
|
315
|
+
base_result = super().calculate_sms_delivery_risk(missive)
|
|
316
|
+
|
|
317
|
+
if not base_result.get("should_send", True):
|
|
318
|
+
return base_result
|
|
319
|
+
|
|
320
|
+
factors = dict(base_result.get("factors", {}))
|
|
321
|
+
recommendations = list(base_result.get("recommendations", []))
|
|
322
|
+
risk_score = float(base_result.get("risk_score", 0))
|
|
323
|
+
|
|
324
|
+
if "BREVO_API_KEY" not in self._config:
|
|
325
|
+
recommendations.append("Missing BREVO_API_KEY in configuration")
|
|
326
|
+
base_result.update(
|
|
327
|
+
{
|
|
328
|
+
"risk_score": 100,
|
|
329
|
+
"risk_level": "critical",
|
|
330
|
+
"factors": factors,
|
|
331
|
+
"recommendations": recommendations,
|
|
332
|
+
"should_send": False,
|
|
333
|
+
}
|
|
334
|
+
)
|
|
335
|
+
return base_result
|
|
336
|
+
|
|
337
|
+
sender = self._config.get("BREVO_SMS_SENDER")
|
|
338
|
+
if not sender:
|
|
339
|
+
recommendations.append("BREVO_SMS_SENDER missing (highly recommended)")
|
|
340
|
+
risk_score = max(risk_score, 60)
|
|
341
|
+
|
|
342
|
+
service_status = self.get_service_status()
|
|
343
|
+
factors["service_status"] = service_status
|
|
344
|
+
if service_status.get("is_available") is False:
|
|
345
|
+
risk_score = min(100.0, risk_score + 40)
|
|
346
|
+
recommendations.append("Brevo SMS service currently unavailable")
|
|
347
|
+
|
|
348
|
+
risk_level = self._calculate_risk_level(int(risk_score))
|
|
349
|
+
should_send = risk_score < 70
|
|
350
|
+
|
|
351
|
+
base_result.update(
|
|
352
|
+
{
|
|
353
|
+
"risk_score": int(risk_score),
|
|
354
|
+
"risk_level": risk_level,
|
|
355
|
+
"factors": factors,
|
|
356
|
+
"recommendations": recommendations,
|
|
357
|
+
"should_send": should_send,
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
return base_result
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
__all__ = ["BrevoProvider"]
|