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.
Files changed (95) hide show
  1. pymissive/__init__.py +3 -0
  2. pymissive/__main__.py +9 -0
  3. pymissive/archives/__init__.py +142 -0
  4. pymissive/archives/__main__.py +9 -0
  5. pymissive/archives/address.py +272 -0
  6. pymissive/archives/address_backends/__init__.py +29 -0
  7. pymissive/archives/address_backends/base.py +610 -0
  8. pymissive/archives/address_backends/geoapify.py +221 -0
  9. pymissive/archives/address_backends/geocode_earth.py +210 -0
  10. pymissive/archives/address_backends/google_maps.py +371 -0
  11. pymissive/archives/address_backends/here.py +348 -0
  12. pymissive/archives/address_backends/locationiq.py +271 -0
  13. pymissive/archives/address_backends/mapbox.py +314 -0
  14. pymissive/archives/address_backends/maps_co.py +257 -0
  15. pymissive/archives/address_backends/nominatim.py +348 -0
  16. pymissive/archives/address_backends/opencage.py +292 -0
  17. pymissive/archives/address_backends/pelias_mixin.py +181 -0
  18. pymissive/archives/address_backends/photon.py +322 -0
  19. pymissive/archives/cli.py +42 -0
  20. pymissive/archives/helpers.py +45 -0
  21. pymissive/archives/missive.py +64 -0
  22. pymissive/archives/providers/__init__.py +167 -0
  23. pymissive/archives/providers/apn.py +171 -0
  24. pymissive/archives/providers/ar24.py +204 -0
  25. pymissive/archives/providers/base/__init__.py +203 -0
  26. pymissive/archives/providers/base/_attachments.py +166 -0
  27. pymissive/archives/providers/base/branded.py +341 -0
  28. pymissive/archives/providers/base/common.py +781 -0
  29. pymissive/archives/providers/base/email.py +422 -0
  30. pymissive/archives/providers/base/email_message.py +85 -0
  31. pymissive/archives/providers/base/monitoring.py +150 -0
  32. pymissive/archives/providers/base/notification.py +187 -0
  33. pymissive/archives/providers/base/postal.py +742 -0
  34. pymissive/archives/providers/base/postal_defaults.py +26 -0
  35. pymissive/archives/providers/base/sms.py +213 -0
  36. pymissive/archives/providers/base/voice_call.py +82 -0
  37. pymissive/archives/providers/brevo.py +363 -0
  38. pymissive/archives/providers/certeurope.py +249 -0
  39. pymissive/archives/providers/django_email.py +182 -0
  40. pymissive/archives/providers/fcm.py +91 -0
  41. pymissive/archives/providers/laposte.py +392 -0
  42. pymissive/archives/providers/maileva.py +511 -0
  43. pymissive/archives/providers/mailgun.py +118 -0
  44. pymissive/archives/providers/messenger.py +74 -0
  45. pymissive/archives/providers/notification.py +112 -0
  46. pymissive/archives/providers/sendgrid.py +160 -0
  47. pymissive/archives/providers/ses.py +185 -0
  48. pymissive/archives/providers/signal.py +68 -0
  49. pymissive/archives/providers/slack.py +80 -0
  50. pymissive/archives/providers/smtp.py +190 -0
  51. pymissive/archives/providers/teams.py +91 -0
  52. pymissive/archives/providers/telegram.py +69 -0
  53. pymissive/archives/providers/twilio.py +310 -0
  54. pymissive/archives/providers/vonage.py +208 -0
  55. pymissive/archives/sender.py +339 -0
  56. pymissive/archives/status.py +22 -0
  57. pymissive/cli.py +42 -0
  58. pymissive/config.py +397 -0
  59. pymissive/helpers.py +0 -0
  60. pymissive/providers/apn.py +8 -0
  61. pymissive/providers/ar24.py +8 -0
  62. pymissive/providers/base/__init__.py +64 -0
  63. pymissive/providers/base/acknowledgement.py +6 -0
  64. pymissive/providers/base/attachments.py +10 -0
  65. pymissive/providers/base/branded.py +16 -0
  66. pymissive/providers/base/email.py +2 -0
  67. pymissive/providers/base/notification.py +2 -0
  68. pymissive/providers/base/postal.py +2 -0
  69. pymissive/providers/base/sms.py +2 -0
  70. pymissive/providers/base/voice_call.py +2 -0
  71. pymissive/providers/brevo.py +420 -0
  72. pymissive/providers/certeurope.py +8 -0
  73. pymissive/providers/django_email.py +8 -0
  74. pymissive/providers/fcm.py +8 -0
  75. pymissive/providers/laposte.py +8 -0
  76. pymissive/providers/maileva.py +8 -0
  77. pymissive/providers/mailgun.py +8 -0
  78. pymissive/providers/messenger.py +8 -0
  79. pymissive/providers/notification.py +8 -0
  80. pymissive/providers/partner.py +650 -0
  81. pymissive/providers/scaleway.py +498 -0
  82. pymissive/providers/sendgrid.py +8 -0
  83. pymissive/providers/ses.py +8 -0
  84. pymissive/providers/signal.py +8 -0
  85. pymissive/providers/slack.py +8 -0
  86. pymissive/providers/smtp.py +8 -0
  87. pymissive/providers/teams.py +8 -0
  88. pymissive/providers/telegram.py +8 -0
  89. pymissive/providers/twilio.py +8 -0
  90. pymissive/providers/vonage.py +8 -0
  91. python_missive-0.2.0.dist-info/METADATA +152 -0
  92. python_missive-0.2.0.dist-info/RECORD +95 -0
  93. python_missive-0.2.0.dist-info/WHEEL +5 -0
  94. python_missive-0.2.0.dist-info/entry_points.txt +2 -0
  95. 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"]