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,781 @@
|
|
|
1
|
+
"""Framework-agnostic provider base classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from collections.abc import MutableMapping
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from ...status import MissiveStatus
|
|
13
|
+
|
|
14
|
+
EventLogger = Callable[[Dict[str, Any]], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseProviderCommon:
|
|
18
|
+
"""Base provider with light helpers, detached from Django."""
|
|
19
|
+
|
|
20
|
+
name: str = "Base"
|
|
21
|
+
supported_types: list[str] = []
|
|
22
|
+
services: list[str] = []
|
|
23
|
+
brands: list[str] = []
|
|
24
|
+
config_keys: list[str] = []
|
|
25
|
+
required_packages: list[str] = []
|
|
26
|
+
status_url: Optional[str] = None
|
|
27
|
+
documentation_url: Optional[str] = None
|
|
28
|
+
site_url: Optional[str] = None
|
|
29
|
+
description_text: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
missive: Optional[Any] = None,
|
|
34
|
+
config: Optional[Dict[str, Any]] = None,
|
|
35
|
+
event_logger: Optional[EventLogger] = None,
|
|
36
|
+
clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
|
37
|
+
):
|
|
38
|
+
"""Initialise the provider with optional missive and config."""
|
|
39
|
+
self.missive = missive
|
|
40
|
+
self._raw_config: Dict[str, Any] = dict(config or {})
|
|
41
|
+
self._config: Dict[str, Any] = self._filter_config(self._raw_config)
|
|
42
|
+
self._config_accessor: Optional["_ConfigAccessor"] = None
|
|
43
|
+
self._event_logger = event_logger or (lambda payload: None)
|
|
44
|
+
self._clock = clock
|
|
45
|
+
|
|
46
|
+
def _filter_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
47
|
+
"""Extract the subset of config keys declared by the provider."""
|
|
48
|
+
if not self.config_keys:
|
|
49
|
+
return dict(config)
|
|
50
|
+
return {key: config[key] for key in self.config_keys if key in config}
|
|
51
|
+
|
|
52
|
+
def _get_missive_value(self, attribute: str, default: Any = None) -> Any:
|
|
53
|
+
"""Retrieve an attribute or zero-argument callable from the missive."""
|
|
54
|
+
if not self.missive:
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
# Security: attribute parameter comes from internal code, not user input
|
|
58
|
+
# This is a private method used only by provider implementations
|
|
59
|
+
value = getattr(self.missive, attribute, default)
|
|
60
|
+
|
|
61
|
+
if callable(value):
|
|
62
|
+
try:
|
|
63
|
+
return value()
|
|
64
|
+
except TypeError:
|
|
65
|
+
return default
|
|
66
|
+
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Capabilities helpers
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def supports(self, missive_type: str) -> bool:
|
|
74
|
+
"""Return True if the provider handles the given missive type."""
|
|
75
|
+
return missive_type in self.supported_types
|
|
76
|
+
|
|
77
|
+
def _get_services(self) -> list[str]:
|
|
78
|
+
"""
|
|
79
|
+
Return the list of declared services, falling back to supported types.
|
|
80
|
+
|
|
81
|
+
Providers can override `services` to expose finer-grained capabilities
|
|
82
|
+
(e.g. marketing vs transactional email). When not set, we derive the list
|
|
83
|
+
from supported types to avoid duplication requirements.
|
|
84
|
+
"""
|
|
85
|
+
declared = list(self.services or [])
|
|
86
|
+
if declared:
|
|
87
|
+
return declared
|
|
88
|
+
|
|
89
|
+
normalized: list[str] = []
|
|
90
|
+
seen: set[str] = set()
|
|
91
|
+
for missive_type in self.supported_types:
|
|
92
|
+
token = str(missive_type).strip().lower()
|
|
93
|
+
if not token or token in seen:
|
|
94
|
+
continue
|
|
95
|
+
seen.add(token)
|
|
96
|
+
normalized.append(token)
|
|
97
|
+
return normalized
|
|
98
|
+
|
|
99
|
+
def configure(
|
|
100
|
+
self, config: Dict[str, Any], *, replace: bool = False
|
|
101
|
+
) -> "BaseProviderCommon":
|
|
102
|
+
"""Update provider configuration (filtered by config_keys)."""
|
|
103
|
+
if replace:
|
|
104
|
+
self._raw_config = dict(config or {})
|
|
105
|
+
else:
|
|
106
|
+
self._raw_config.update(config or {})
|
|
107
|
+
self._config = self._filter_config(self._raw_config)
|
|
108
|
+
if self._config_accessor is not None:
|
|
109
|
+
self._config_accessor.refresh()
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def config(self) -> "_ConfigAccessor":
|
|
114
|
+
"""Return a proxy to configuration dict, callable for updates."""
|
|
115
|
+
if self._config_accessor is None:
|
|
116
|
+
self._config_accessor = _ConfigAccessor(self)
|
|
117
|
+
return self._config_accessor
|
|
118
|
+
|
|
119
|
+
def has_service(self, service: str) -> bool:
|
|
120
|
+
"""Return True if the provider exposes the given service name."""
|
|
121
|
+
return service in self._get_services()
|
|
122
|
+
|
|
123
|
+
def check_package(self, package_name: str) -> bool:
|
|
124
|
+
"""Check if a required package is installed.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
package_name: Name of the package to check
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True if the package can be imported, False otherwise
|
|
131
|
+
"""
|
|
132
|
+
import importlib
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
importlib.import_module(package_name)
|
|
136
|
+
return True
|
|
137
|
+
except ImportError:
|
|
138
|
+
# Try with hyphens replaced by underscores (e.g., sib-api-v3-sdk -> sib_api_v3_sdk)
|
|
139
|
+
try:
|
|
140
|
+
importlib.import_module(package_name.replace("-", "_"))
|
|
141
|
+
return True
|
|
142
|
+
except ImportError:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
def check_required_packages(self) -> Dict[str, bool]:
|
|
146
|
+
"""Check all required packages and return their installation status.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dict mapping package names to their installation status
|
|
150
|
+
"""
|
|
151
|
+
return {
|
|
152
|
+
package: self.check_package(package) for package in self.required_packages
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def check_config_keys(
|
|
156
|
+
self, config: Optional[Dict[str, Any]] = None
|
|
157
|
+
) -> Dict[str, bool]:
|
|
158
|
+
"""Check if all config_keys are present in the provided configuration.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
config: Configuration dict to check (defaults to self._raw_config)
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Dict mapping config key names to their presence status
|
|
165
|
+
"""
|
|
166
|
+
if config is None:
|
|
167
|
+
config = self._raw_config
|
|
168
|
+
return {key: key in config for key in self.config_keys}
|
|
169
|
+
|
|
170
|
+
def check_package_and_config(
|
|
171
|
+
self, config: Optional[Dict[str, Any]] = None
|
|
172
|
+
) -> Dict[str, Any]:
|
|
173
|
+
"""Check both required packages and configuration keys.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
config: Configuration dict to check (defaults to self._raw_config)
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Dict with 'packages' and 'config' keys containing their respective status dicts
|
|
180
|
+
"""
|
|
181
|
+
return {
|
|
182
|
+
"packages": self.check_required_packages(),
|
|
183
|
+
"config": self.check_config_keys(config),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
# Missive state helpers
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def _update_status(
|
|
191
|
+
self,
|
|
192
|
+
status: MissiveStatus,
|
|
193
|
+
provider: Optional[str] = None,
|
|
194
|
+
external_id: Optional[str] = None,
|
|
195
|
+
error_message: Optional[str] = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Update missive attributes when a lifecycle event occurs."""
|
|
198
|
+
if not self.missive:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if hasattr(self.missive, "status"):
|
|
202
|
+
self.missive.status = status
|
|
203
|
+
if provider and hasattr(self.missive, "provider"):
|
|
204
|
+
self.missive.provider = provider
|
|
205
|
+
if external_id and hasattr(self.missive, "external_id"):
|
|
206
|
+
self.missive.external_id = external_id
|
|
207
|
+
if error_message and hasattr(self.missive, "error_message"):
|
|
208
|
+
self.missive.error_message = error_message
|
|
209
|
+
|
|
210
|
+
clock_fn = getattr(self, "_clock", None)
|
|
211
|
+
timestamp = clock_fn() if callable(clock_fn) else datetime.now(timezone.utc)
|
|
212
|
+
if status == MissiveStatus.SENT and hasattr(self.missive, "sent_at"):
|
|
213
|
+
self.missive.sent_at = timestamp
|
|
214
|
+
elif status == MissiveStatus.DELIVERED and hasattr(
|
|
215
|
+
self.missive, "delivered_at"
|
|
216
|
+
):
|
|
217
|
+
self.missive.delivered_at = timestamp
|
|
218
|
+
elif status == MissiveStatus.READ and hasattr(self.missive, "read_at"):
|
|
219
|
+
self.missive.read_at = timestamp
|
|
220
|
+
|
|
221
|
+
save_method = getattr(self.missive, "save", None)
|
|
222
|
+
if callable(save_method):
|
|
223
|
+
save_method()
|
|
224
|
+
|
|
225
|
+
def _create_event(
|
|
226
|
+
self,
|
|
227
|
+
event_type: str,
|
|
228
|
+
description: str = "",
|
|
229
|
+
status: Optional[MissiveStatus] = None,
|
|
230
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Notify an external event logger about a provider occurrence."""
|
|
233
|
+
if not self.missive:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
payload = {
|
|
237
|
+
"missive": self.missive,
|
|
238
|
+
"provider": self.name,
|
|
239
|
+
"event_type": event_type,
|
|
240
|
+
"description": description,
|
|
241
|
+
"status": status,
|
|
242
|
+
"metadata": metadata or {},
|
|
243
|
+
"occurred_at": self._clock(),
|
|
244
|
+
}
|
|
245
|
+
self._event_logger(payload)
|
|
246
|
+
|
|
247
|
+
def get_status_from_event(self, event_type: str) -> Optional[MissiveStatus]:
|
|
248
|
+
"""Map a raw provider event name to a MissiveStatus."""
|
|
249
|
+
event_mapping = {
|
|
250
|
+
"delivered": MissiveStatus.DELIVERED,
|
|
251
|
+
"opened": MissiveStatus.READ,
|
|
252
|
+
"clicked": MissiveStatus.READ,
|
|
253
|
+
"read": MissiveStatus.READ,
|
|
254
|
+
"bounced": MissiveStatus.FAILED,
|
|
255
|
+
"failed": MissiveStatus.FAILED,
|
|
256
|
+
"rejected": MissiveStatus.FAILED,
|
|
257
|
+
"dropped": MissiveStatus.FAILED,
|
|
258
|
+
}
|
|
259
|
+
return event_mapping.get(event_type.lower())
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# Proofs and service metadata
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def get_proofs_of_delivery(self, service_type: Optional[str] = None) -> list:
|
|
266
|
+
"""Return delivery proofs for the missive (override in subclasses)."""
|
|
267
|
+
if not self.missive:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
service_type = service_type or self._detect_service_type()
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
def _detect_service_type(self) -> str:
|
|
274
|
+
"""Infer service type from the missive object."""
|
|
275
|
+
missive_type = str(getattr(self.missive, "missive_type", "")).strip()
|
|
276
|
+
if not missive_type:
|
|
277
|
+
return "unknown"
|
|
278
|
+
|
|
279
|
+
normalized = missive_type.upper()
|
|
280
|
+
|
|
281
|
+
if normalized.startswith("POSTAL"):
|
|
282
|
+
return self._resolve_postal_service_variant(normalized)
|
|
283
|
+
|
|
284
|
+
if normalized == "EMAIL":
|
|
285
|
+
return (
|
|
286
|
+
"email_ar" if getattr(self.missive, "is_registered", False) else "email"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if normalized == "BRANDED":
|
|
290
|
+
return self.name.lower()
|
|
291
|
+
|
|
292
|
+
return normalized.lower()
|
|
293
|
+
|
|
294
|
+
def _resolve_postal_service_variant(self, type_token: str) -> str:
|
|
295
|
+
"""Map a postal missive type to its service identifier."""
|
|
296
|
+
mapping = {
|
|
297
|
+
"POSTAL": "postal",
|
|
298
|
+
"POSTAL_REGISTERED": "postal_registered",
|
|
299
|
+
"POSTAL_SIGNATURE": "postal_signature",
|
|
300
|
+
}
|
|
301
|
+
return mapping.get(type_token, type_token.lower())
|
|
302
|
+
|
|
303
|
+
def list_available_proofs(self) -> Dict[str, bool]:
|
|
304
|
+
"""Return proof availability keyed by service type."""
|
|
305
|
+
if not self.missive:
|
|
306
|
+
return {}
|
|
307
|
+
|
|
308
|
+
service_type = self._detect_service_type()
|
|
309
|
+
proof_services = {"lre", "postal_registered", "postal_signature", "email_ar"}
|
|
310
|
+
return {service_type: service_type in proof_services}
|
|
311
|
+
|
|
312
|
+
def check_service_availability(self) -> Dict[str, Any]:
|
|
313
|
+
"""Return lightweight service availability information."""
|
|
314
|
+
return {
|
|
315
|
+
"is_available": None,
|
|
316
|
+
"response_time_ms": 0,
|
|
317
|
+
"quota_remaining": None,
|
|
318
|
+
"status": "unknown",
|
|
319
|
+
"last_check": self._get_last_check_time(),
|
|
320
|
+
"warnings": ["Service availability check not implemented"],
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
def get_service_status(self) -> Dict[str, Any]:
|
|
324
|
+
"""Provide a default status payload for monitoring dashboards."""
|
|
325
|
+
return {
|
|
326
|
+
"status": "unknown",
|
|
327
|
+
"is_available": None,
|
|
328
|
+
"services": self._get_services(),
|
|
329
|
+
"credits": {
|
|
330
|
+
"type": "unknown",
|
|
331
|
+
"remaining": None,
|
|
332
|
+
"currency": "",
|
|
333
|
+
"limit": None,
|
|
334
|
+
"percentage": None,
|
|
335
|
+
},
|
|
336
|
+
"last_check": self._get_last_check_time(),
|
|
337
|
+
"warnings": ["get_service_status() not implemented for this provider"],
|
|
338
|
+
"details": {},
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
def validate(self) -> tuple[bool, str]:
|
|
342
|
+
"""
|
|
343
|
+
Validate provider configuration and missive.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Tuple of (is_valid, error_message). Default implementation
|
|
347
|
+
checks that required config keys are present.
|
|
348
|
+
"""
|
|
349
|
+
if not self.missive:
|
|
350
|
+
return False, "Missive not defined"
|
|
351
|
+
|
|
352
|
+
# Check required config keys
|
|
353
|
+
missing_keys = [key for key in self.config_keys if key not in self._raw_config]
|
|
354
|
+
if missing_keys:
|
|
355
|
+
return (
|
|
356
|
+
False,
|
|
357
|
+
f"Missing required configuration keys: {', '.join(missing_keys)}",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Enforce geographic scope config per service family
|
|
361
|
+
families = self._detect_service_families()
|
|
362
|
+
missing_geo: list[str] = []
|
|
363
|
+
invalid_geo: list[str] = []
|
|
364
|
+
for family in sorted(families):
|
|
365
|
+
key = f"{family}_geo"
|
|
366
|
+
if key not in self._raw_config:
|
|
367
|
+
attr_name = f"{family}_geographic_coverage"
|
|
368
|
+
fallback_attr = f"{family}_geo"
|
|
369
|
+
attr_value = getattr(self, attr_name, None)
|
|
370
|
+
if attr_value is None:
|
|
371
|
+
attr_value = getattr(self, fallback_attr, None)
|
|
372
|
+
if attr_value is None:
|
|
373
|
+
missing_geo.append(key)
|
|
374
|
+
continue
|
|
375
|
+
# If provided via attribute, inject into config for downstream logic
|
|
376
|
+
self._raw_config[key] = attr_value
|
|
377
|
+
value = self._raw_config.get(key)
|
|
378
|
+
ok, msg = self._validate_geo_config(value)
|
|
379
|
+
if not ok:
|
|
380
|
+
invalid_geo.append(f"{key}: {msg}")
|
|
381
|
+
|
|
382
|
+
if missing_geo:
|
|
383
|
+
return (
|
|
384
|
+
False,
|
|
385
|
+
"Missing geographic configuration for services: "
|
|
386
|
+
+ ", ".join(missing_geo),
|
|
387
|
+
)
|
|
388
|
+
if invalid_geo:
|
|
389
|
+
return (
|
|
390
|
+
False,
|
|
391
|
+
"Invalid geographic configuration — " + " | ".join(invalid_geo),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
return True, ""
|
|
395
|
+
|
|
396
|
+
def _calculate_risk_level(self, risk_score: int) -> str:
|
|
397
|
+
"""Calculate risk level from risk score using standard thresholds."""
|
|
398
|
+
if risk_score < 25:
|
|
399
|
+
return "low"
|
|
400
|
+
elif risk_score < 50:
|
|
401
|
+
return "medium"
|
|
402
|
+
elif risk_score < 75:
|
|
403
|
+
return "high"
|
|
404
|
+
else:
|
|
405
|
+
return "critical"
|
|
406
|
+
|
|
407
|
+
def _get_last_check_time(self) -> datetime:
|
|
408
|
+
"""Get the last check time using the provider's clock."""
|
|
409
|
+
clock = getattr(self, "_clock", None)
|
|
410
|
+
return clock() if callable(clock) else datetime.now(timezone.utc)
|
|
411
|
+
|
|
412
|
+
def _build_generic_service_status(
|
|
413
|
+
self,
|
|
414
|
+
*,
|
|
415
|
+
credits_type: str,
|
|
416
|
+
rate_limits: Dict[str, Any],
|
|
417
|
+
credits_currency: str = "",
|
|
418
|
+
credits_remaining: Optional[Any] = None,
|
|
419
|
+
credits_limit: Optional[Any] = None,
|
|
420
|
+
credits_percentage: Optional[Any] = None,
|
|
421
|
+
warnings: Optional[List[str]] = None,
|
|
422
|
+
details: Optional[Dict[str, Any]] = None,
|
|
423
|
+
sla: Optional[Dict[str, Any]] = None,
|
|
424
|
+
status: str = "unknown",
|
|
425
|
+
is_available: Optional[bool] = None,
|
|
426
|
+
) -> Dict[str, Any]:
|
|
427
|
+
"""Build a standardized service status payload."""
|
|
428
|
+
return {
|
|
429
|
+
"status": status,
|
|
430
|
+
"is_available": is_available,
|
|
431
|
+
"services": self._get_services(),
|
|
432
|
+
"credits": {
|
|
433
|
+
"type": credits_type,
|
|
434
|
+
"remaining": credits_remaining,
|
|
435
|
+
"currency": credits_currency,
|
|
436
|
+
"limit": credits_limit,
|
|
437
|
+
"percentage": credits_percentage,
|
|
438
|
+
},
|
|
439
|
+
"rate_limits": rate_limits,
|
|
440
|
+
"sla": sla or {},
|
|
441
|
+
"last_check": self._get_last_check_time(),
|
|
442
|
+
"warnings": warnings or [],
|
|
443
|
+
"details": details or {},
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
def _risk_missing_missive_payload(self) -> Dict[str, Any]:
|
|
447
|
+
"""Standard payload when no missive is available for risk analyses."""
|
|
448
|
+
return {
|
|
449
|
+
"risk_score": 100,
|
|
450
|
+
"risk_level": "critical",
|
|
451
|
+
"factors": {},
|
|
452
|
+
"recommendations": ["No missive to analyze"],
|
|
453
|
+
"should_send": False,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
def _resolve_risk_target(
|
|
457
|
+
self, missive: Optional[Any]
|
|
458
|
+
) -> Tuple[Optional[Any], Optional[Dict[str, Any]]]:
|
|
459
|
+
"""Return the missive to analyze or a fallback payload if missing."""
|
|
460
|
+
target = missive if missive is not None else self.missive
|
|
461
|
+
if target is None:
|
|
462
|
+
return None, self._risk_missing_missive_payload()
|
|
463
|
+
return target, None
|
|
464
|
+
|
|
465
|
+
def _start_risk_analysis(
|
|
466
|
+
self, missive: Optional[Any]
|
|
467
|
+
) -> Tuple[Optional[Any], Optional[Dict[str, Any]], Dict[str, Any], List[str], float]:
|
|
468
|
+
"""Standardise the setup for risk analysis routines.
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Tuple of:
|
|
472
|
+
- target missive (or None if unavailable)
|
|
473
|
+
- fallback payload to return immediately if provided
|
|
474
|
+
- factors dict
|
|
475
|
+
- recommendations list
|
|
476
|
+
- starting risk score (float)
|
|
477
|
+
"""
|
|
478
|
+
target, fallback = self._resolve_risk_target(missive)
|
|
479
|
+
if fallback is not None:
|
|
480
|
+
return None, fallback, {}, [], 0.0
|
|
481
|
+
return target, None, {}, [], 0.0
|
|
482
|
+
|
|
483
|
+
def _run_risk_analysis(
|
|
484
|
+
self,
|
|
485
|
+
missive: Optional[Any],
|
|
486
|
+
handler: Callable[[Any, Dict[str, Any], List[str], float], Dict[str, Any]],
|
|
487
|
+
) -> Dict[str, Any]:
|
|
488
|
+
"""Execute a provider-specific risk handler with shared pre-checks."""
|
|
489
|
+
target, fallback, factors, recommendations, total_risk = self._start_risk_analysis(
|
|
490
|
+
missive
|
|
491
|
+
)
|
|
492
|
+
if fallback is not None or target is None:
|
|
493
|
+
return fallback or self._risk_missing_missive_payload()
|
|
494
|
+
return handler(target, factors, recommendations, total_risk)
|
|
495
|
+
|
|
496
|
+
def _handle_send_error(
|
|
497
|
+
self, error: Exception, error_message: Optional[str] = None
|
|
498
|
+
) -> bool:
|
|
499
|
+
"""Handle errors during send operations with consistent error reporting."""
|
|
500
|
+
msg = error_message or str(error)
|
|
501
|
+
self._update_status(MissiveStatus.FAILED, error_message=msg)
|
|
502
|
+
self._create_event("failed", msg)
|
|
503
|
+
return False
|
|
504
|
+
|
|
505
|
+
def _simulate_send(
|
|
506
|
+
self,
|
|
507
|
+
*,
|
|
508
|
+
prefix: str,
|
|
509
|
+
event_message: str,
|
|
510
|
+
status: MissiveStatus = MissiveStatus.SENT,
|
|
511
|
+
event_type: str = "sent",
|
|
512
|
+
) -> bool:
|
|
513
|
+
"""Simulate a successful send by updating status and logging an event."""
|
|
514
|
+
missive_id = getattr(self.missive, "id", "unknown") if self.missive else "unknown"
|
|
515
|
+
external_id = f"{prefix}_{missive_id}"
|
|
516
|
+
self._update_status(status, provider=self.name, external_id=external_id)
|
|
517
|
+
self._create_event(event_type, event_message)
|
|
518
|
+
return True
|
|
519
|
+
|
|
520
|
+
def _send_email_simulation(
|
|
521
|
+
self,
|
|
522
|
+
*,
|
|
523
|
+
prefix: str,
|
|
524
|
+
event_message: str,
|
|
525
|
+
recipient_field: str = "recipient_email",
|
|
526
|
+
) -> bool:
|
|
527
|
+
"""Validate recipient and simulate an email send."""
|
|
528
|
+
is_valid, error = self._validate_and_check_recipient(
|
|
529
|
+
recipient_field, "Email missing"
|
|
530
|
+
)
|
|
531
|
+
if not is_valid:
|
|
532
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
try:
|
|
536
|
+
return self._simulate_send(prefix=prefix, event_message=event_message)
|
|
537
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
538
|
+
return self._handle_send_error(exc)
|
|
539
|
+
|
|
540
|
+
def _validate_and_check_recipient(
|
|
541
|
+
self, recipient_field: str, error_message: str
|
|
542
|
+
) -> tuple[bool, Optional[str]]:
|
|
543
|
+
"""Validate provider and check recipient field exists."""
|
|
544
|
+
is_valid, error = self.validate()
|
|
545
|
+
if not is_valid:
|
|
546
|
+
return False, error
|
|
547
|
+
|
|
548
|
+
recipient = self._get_missive_value(recipient_field)
|
|
549
|
+
if not recipient:
|
|
550
|
+
return False, error_message
|
|
551
|
+
|
|
552
|
+
return True, None
|
|
553
|
+
|
|
554
|
+
def calculate_delivery_risk(self, missive: Optional[Any] = None) -> Dict[str, Any]:
|
|
555
|
+
"""Compute a delivery risk score for the given missive."""
|
|
556
|
+
|
|
557
|
+
def _handler(
|
|
558
|
+
target_missive: Any,
|
|
559
|
+
factors: Dict[str, Any],
|
|
560
|
+
recommendations: List[str],
|
|
561
|
+
total_risk: float,
|
|
562
|
+
) -> Dict[str, Any]:
|
|
563
|
+
missive_type = str(getattr(target_missive, "missive_type", "")).upper()
|
|
564
|
+
updated_risk = total_risk
|
|
565
|
+
|
|
566
|
+
if missive_type == "EMAIL":
|
|
567
|
+
email = self._get_missive_value("get_recipient_email") or getattr(
|
|
568
|
+
target_missive, "recipient_email", None
|
|
569
|
+
)
|
|
570
|
+
if email:
|
|
571
|
+
email_validation = self.validate_email(email)
|
|
572
|
+
factors["email_validation"] = email_validation
|
|
573
|
+
updated_risk += email_validation["risk_score"] * 0.6
|
|
574
|
+
recommendations.extend(email_validation.get("warnings", []))
|
|
575
|
+
|
|
576
|
+
elif missive_type == "SMS" and hasattr(self, "calculate_sms_delivery_risk"):
|
|
577
|
+
sms_risk = self.calculate_sms_delivery_risk(target_missive)
|
|
578
|
+
factors["sms_risk"] = sms_risk
|
|
579
|
+
updated_risk += sms_risk.get("risk_score", 0) * 0.6
|
|
580
|
+
recommendations.extend(sms_risk.get("recommendations", []))
|
|
581
|
+
|
|
582
|
+
elif missive_type == "BRANDED":
|
|
583
|
+
phone = self._get_missive_value("get_recipient_phone") or getattr(
|
|
584
|
+
target_missive, "recipient_phone", None
|
|
585
|
+
)
|
|
586
|
+
if phone:
|
|
587
|
+
phone_validation = self.validate_phone_number(phone)
|
|
588
|
+
factors["phone_validation"] = phone_validation
|
|
589
|
+
updated_risk += phone_validation["risk_score"] * 0.6
|
|
590
|
+
recommendations.extend(phone_validation.get("warnings", []))
|
|
591
|
+
|
|
592
|
+
elif missive_type == "PUSH_NOTIFICATION" and hasattr(
|
|
593
|
+
self, "calculate_push_notification_delivery_risk"
|
|
594
|
+
):
|
|
595
|
+
push_risk = self.calculate_push_notification_delivery_risk(target_missive)
|
|
596
|
+
factors["push_notification_risk"] = push_risk
|
|
597
|
+
updated_risk += push_risk.get("risk_score", 0) * 0.6
|
|
598
|
+
recommendations.extend(push_risk.get("recommendations", []))
|
|
599
|
+
|
|
600
|
+
service_check = self.check_service_availability()
|
|
601
|
+
factors["service_availability"] = service_check
|
|
602
|
+
if not service_check.get("is_available"):
|
|
603
|
+
updated_risk += 20
|
|
604
|
+
recommendations.append("Service temporarily unavailable")
|
|
605
|
+
|
|
606
|
+
risk_score = min(int(updated_risk), 100)
|
|
607
|
+
risk_level = self._calculate_risk_level(risk_score)
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
"risk_score": risk_score,
|
|
611
|
+
"risk_level": risk_level,
|
|
612
|
+
"factors": factors,
|
|
613
|
+
"recommendations": recommendations,
|
|
614
|
+
"should_send": risk_score < 70,
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return self._run_risk_analysis(missive, _handler)
|
|
618
|
+
|
|
619
|
+
# ------------------------------------------------------------------
|
|
620
|
+
# Geographic scope handling
|
|
621
|
+
# ------------------------------------------------------------------
|
|
622
|
+
_COUNTRIES_INDEX: Dict[str, set] | None = None
|
|
623
|
+
|
|
624
|
+
@classmethod
|
|
625
|
+
def _load_countries_index(cls) -> Dict[str, set]:
|
|
626
|
+
if cls._COUNTRIES_INDEX is not None:
|
|
627
|
+
return cls._COUNTRIES_INDEX
|
|
628
|
+
|
|
629
|
+
# Find data/countries.csv by walking up from this file location
|
|
630
|
+
csv_path: Path | None = None
|
|
631
|
+
here = Path(__file__).resolve()
|
|
632
|
+
for parent in [here, *here.parents]:
|
|
633
|
+
candidate = None
|
|
634
|
+
if len(parent.parents) >= 5:
|
|
635
|
+
candidate = parent.parents[4] / "data" / "countries.csv"
|
|
636
|
+
if candidate and candidate.exists():
|
|
637
|
+
csv_path = candidate
|
|
638
|
+
break
|
|
639
|
+
# Fallback: project layout where src/ is directly under root
|
|
640
|
+
candidate2 = parent.parent / "data" / "countries.csv"
|
|
641
|
+
if candidate2.exists():
|
|
642
|
+
csv_path = candidate2
|
|
643
|
+
break
|
|
644
|
+
|
|
645
|
+
regions: set[str] = set()
|
|
646
|
+
subregions: set[str] = set()
|
|
647
|
+
countries: set[str] = set()
|
|
648
|
+
names: set[str] = set()
|
|
649
|
+
if csv_path and csv_path.exists():
|
|
650
|
+
with suppress(Exception), csv_path.open("r", encoding="utf-8") as fh:
|
|
651
|
+
reader = csv.DictReader(fh)
|
|
652
|
+
for row in reader:
|
|
653
|
+
cca2 = (row.get("cca2") or "").upper()
|
|
654
|
+
cca3 = (row.get("cca3") or "").upper()
|
|
655
|
+
name_common = (row.get("name_common") or "").strip().lower()
|
|
656
|
+
region = (row.get("region") or "").strip()
|
|
657
|
+
subregion = (row.get("subregion") or "").strip()
|
|
658
|
+
if cca2:
|
|
659
|
+
countries.add(cca2)
|
|
660
|
+
if cca3:
|
|
661
|
+
countries.add(cca3)
|
|
662
|
+
if name_common:
|
|
663
|
+
names.add(name_common)
|
|
664
|
+
if region:
|
|
665
|
+
regions.add(region)
|
|
666
|
+
if subregion:
|
|
667
|
+
subregions.add(subregion)
|
|
668
|
+
|
|
669
|
+
cls._COUNTRIES_INDEX = {
|
|
670
|
+
"regions": regions,
|
|
671
|
+
"subregions": subregions,
|
|
672
|
+
"countries": countries,
|
|
673
|
+
"names": names,
|
|
674
|
+
}
|
|
675
|
+
return cls._COUNTRIES_INDEX
|
|
676
|
+
|
|
677
|
+
def _detect_service_families(self) -> set[str]:
|
|
678
|
+
"""Map declared services to canonical families for geo config."""
|
|
679
|
+
families: set[str] = set()
|
|
680
|
+
for service in self._get_services():
|
|
681
|
+
normalized = service.strip().lower()
|
|
682
|
+
if normalized:
|
|
683
|
+
families.add(normalized)
|
|
684
|
+
# Also consider supported_types (e.g., POSTAL_REGISTERED implies same family)
|
|
685
|
+
for t in self.supported_types:
|
|
686
|
+
normalized = str(t).strip().lower()
|
|
687
|
+
if normalized:
|
|
688
|
+
families.add(normalized)
|
|
689
|
+
return families
|
|
690
|
+
|
|
691
|
+
@staticmethod
|
|
692
|
+
def _as_tokens(value: Any) -> list[str] | str:
|
|
693
|
+
if isinstance(value, str):
|
|
694
|
+
if value.strip() == "*":
|
|
695
|
+
return "*"
|
|
696
|
+
if "," in value:
|
|
697
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
698
|
+
return [value.strip()] if value.strip() else []
|
|
699
|
+
if isinstance(value, (list, tuple)):
|
|
700
|
+
tokens: list[str] = []
|
|
701
|
+
for v in value:
|
|
702
|
+
s = str(v).strip()
|
|
703
|
+
if s:
|
|
704
|
+
tokens.append(s)
|
|
705
|
+
return tokens
|
|
706
|
+
return []
|
|
707
|
+
|
|
708
|
+
def _validate_geo_config(self, value: Any) -> tuple[bool, str]:
|
|
709
|
+
tokens = self._as_tokens(value)
|
|
710
|
+
if tokens == "*":
|
|
711
|
+
return True, ""
|
|
712
|
+
idx = self._load_countries_index()
|
|
713
|
+
regions = idx["regions"]
|
|
714
|
+
subregions = idx["subregions"]
|
|
715
|
+
countries = idx["countries"]
|
|
716
|
+
names = idx["names"]
|
|
717
|
+
invalid: list[str] = []
|
|
718
|
+
for tok in tokens or []:
|
|
719
|
+
t_upper = tok.upper()
|
|
720
|
+
t_lower = tok.lower()
|
|
721
|
+
if (
|
|
722
|
+
t_upper in countries
|
|
723
|
+
or t_lower in names
|
|
724
|
+
or tok in regions
|
|
725
|
+
or tok in subregions
|
|
726
|
+
):
|
|
727
|
+
continue
|
|
728
|
+
invalid.append(tok)
|
|
729
|
+
if invalid:
|
|
730
|
+
return False, f"unknown tokens: {', '.join(invalid)}"
|
|
731
|
+
return True, ""
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
class _ConfigAccessor(MutableMapping):
|
|
735
|
+
"""Dictionary-like proxy exposing provider configuration with update helper."""
|
|
736
|
+
|
|
737
|
+
def __init__(self, provider: BaseProviderCommon) -> None:
|
|
738
|
+
self._provider = provider
|
|
739
|
+
|
|
740
|
+
# MutableMapping interface -------------------------------------------------
|
|
741
|
+
def __getitem__(self, key: str) -> Any:
|
|
742
|
+
return self._provider._config[key]
|
|
743
|
+
|
|
744
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
745
|
+
self._provider.configure({key: value})
|
|
746
|
+
|
|
747
|
+
def __delitem__(self, key: str) -> None:
|
|
748
|
+
if key in self._provider._raw_config:
|
|
749
|
+
del self._provider._raw_config[key]
|
|
750
|
+
self._provider._config = self._provider._filter_config(
|
|
751
|
+
self._provider._raw_config
|
|
752
|
+
)
|
|
753
|
+
self.refresh()
|
|
754
|
+
else: # pragma: no cover - defensive
|
|
755
|
+
raise KeyError(key)
|
|
756
|
+
|
|
757
|
+
def __iter__(self):
|
|
758
|
+
return iter(self._provider._config)
|
|
759
|
+
|
|
760
|
+
def __len__(self) -> int:
|
|
761
|
+
return len(self._provider._config)
|
|
762
|
+
|
|
763
|
+
# Convenience helpers -----------------------------------------------------
|
|
764
|
+
def __call__(
|
|
765
|
+
self, config: Dict[str, Any], *, replace: bool = False
|
|
766
|
+
) -> BaseProviderCommon:
|
|
767
|
+
"""Allow provider.config({...}) to update settings."""
|
|
768
|
+
return self._provider.configure(config, replace=replace)
|
|
769
|
+
|
|
770
|
+
def refresh(self) -> None:
|
|
771
|
+
"""Ensure external references observe latest configuration."""
|
|
772
|
+
# no-op: MutableMapping view reads live data
|
|
773
|
+
|
|
774
|
+
def copy(self) -> Dict[str, Any]:
|
|
775
|
+
return dict(self._provider._config)
|
|
776
|
+
|
|
777
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
778
|
+
return self._provider._config.get(key, default)
|
|
779
|
+
|
|
780
|
+
def __repr__(self) -> str: # pragma: no cover - repr only
|
|
781
|
+
return repr(self._provider._config)
|