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