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,203 @@
1
+ """Base provider classes and mixins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional, Tuple
6
+
7
+ from ...status import MissiveStatus
8
+ from .branded import BaseBrandedMixin
9
+ from .common import BaseProviderCommon
10
+ from .email import BaseEmailMixin
11
+ from .monitoring import BaseMonitoringMixin
12
+ from .notification import BaseNotificationMixin
13
+ from .postal import BasePostalMixin
14
+ from .sms import BaseSMSMixin
15
+ from .voice_call import BaseVoiceCallMixin
16
+
17
+
18
+ class BaseProvider(
19
+ BaseProviderCommon,
20
+ BaseEmailMixin,
21
+ BaseSMSMixin,
22
+ BasePostalMixin,
23
+ BaseNotificationMixin,
24
+ BaseVoiceCallMixin,
25
+ BaseMonitoringMixin,
26
+ BaseBrandedMixin,
27
+ ):
28
+ """Base class combining all mixins for convenience."""
29
+
30
+ def _normalize_missive_type(self) -> Optional[str]:
31
+ missive_type = self._get_missive_value("missive_type")
32
+ if missive_type is None:
33
+ return None
34
+ return str(missive_type).upper()
35
+
36
+ def _dispatch_by_type(
37
+ self,
38
+ target: Any,
39
+ *,
40
+ missive_type: Optional[str] = None,
41
+ default: Any = None,
42
+ **kwargs: Any,
43
+ ) -> Any:
44
+ """
45
+ Generic dispatch helper.
46
+
47
+ `target` can be either:
48
+ - a mapping of normalized missive types to handlers
49
+ - a format string expecting the lower-cased missive type (e.g. "send_%s")
50
+ """
51
+ type_name = (missive_type or self._normalize_missive_type() or "").upper()
52
+ if not type_name:
53
+ return default
54
+
55
+ handler: Any
56
+ if isinstance(target, dict):
57
+ handler = target.get(type_name)
58
+ else:
59
+ attr_name = str(target) % type_name.lower()
60
+ handler = getattr(self, attr_name, None)
61
+
62
+ if handler is None:
63
+ return default
64
+
65
+ return handler(**kwargs) if callable(handler) else handler
66
+
67
+ def send(self) -> bool:
68
+ """Send the current missive by dispatching to the appropriate method."""
69
+ if not self.missive:
70
+ return False
71
+
72
+ missive_type = self._normalize_missive_type()
73
+ if not missive_type:
74
+ self._update_status(
75
+ MissiveStatus.FAILED, error_message="Missing missive type"
76
+ )
77
+ return False
78
+
79
+ if not self.supports(missive_type):
80
+ self._update_status(
81
+ MissiveStatus.FAILED,
82
+ error_message=f"{self.name} does not support {missive_type}",
83
+ )
84
+ return False
85
+
86
+ result = self._dispatch_by_type("send_%s", missive_type=missive_type)
87
+ if result is None:
88
+ self._update_status(
89
+ MissiveStatus.FAILED,
90
+ error_message=f"No handler implemented for type {missive_type}",
91
+ )
92
+ return False
93
+
94
+ return result if isinstance(result, bool) else False
95
+
96
+ def check_delivery_status(self, **kwargs) -> Dict[str, Any]:
97
+ """Check delivery status by dispatching to the appropriate method."""
98
+ result = self._dispatch_by_type(
99
+ "check_%s_delivery_status",
100
+ missive_type=kwargs.get("missive_type"),
101
+ default={
102
+ "status": "unknown",
103
+ "error_message": "Missive type not defined or no handler available",
104
+ "details": {},
105
+ },
106
+ **kwargs,
107
+ )
108
+
109
+ if isinstance(result, dict):
110
+ return result
111
+
112
+ return {
113
+ "status": "unknown",
114
+ "error_message": "No delivery status handler available",
115
+ "details": {},
116
+ }
117
+
118
+ def cancel(self) -> bool:
119
+ """Cancel the current missive by dispatching to the appropriate method."""
120
+ if not self.missive or not self._get_missive_value("external_id"):
121
+ return False
122
+
123
+ result = self._dispatch_by_type("cancel_%s")
124
+ return result if isinstance(result, bool) else False
125
+
126
+ def handle_webhook(
127
+ self,
128
+ payload: Dict[str, Any],
129
+ headers: Dict[str, str],
130
+ *,
131
+ missive_type: Optional[str] = None,
132
+ **kwargs: Any,
133
+ ) -> Tuple[bool, str, Optional[Any]]:
134
+ """Dispatch webhook handling to a type-specific implementation."""
135
+ result = self._dispatch_by_type(
136
+ "handle_%s_webhook",
137
+ missive_type=missive_type,
138
+ payload=payload,
139
+ headers=headers,
140
+ **kwargs,
141
+ )
142
+
143
+ if isinstance(result, tuple) and len(result) == 3:
144
+ return result
145
+
146
+ type_name = (missive_type or self._normalize_missive_type() or "").upper()
147
+ return (
148
+ False,
149
+ f"No webhook handler available for type '{type_name or 'unknown'}'",
150
+ None,
151
+ )
152
+
153
+ def validate_webhook_signature(
154
+ self,
155
+ payload: Any,
156
+ headers: Dict[str, str],
157
+ *,
158
+ missive_type: Optional[str] = None,
159
+ **kwargs: Any,
160
+ ) -> Tuple[bool, str]:
161
+ """Dispatch webhook signature validation by missive type."""
162
+ result = self._dispatch_by_type(
163
+ "validate_%s_webhook_signature",
164
+ missive_type=missive_type,
165
+ payload=payload,
166
+ headers=headers,
167
+ **kwargs,
168
+ )
169
+
170
+ if isinstance(result, tuple) and len(result) == 2:
171
+ return result
172
+
173
+ return True, ""
174
+
175
+ def extract_missive_id(
176
+ self,
177
+ payload: Dict[str, Any],
178
+ *,
179
+ missive_type: Optional[str] = None,
180
+ ) -> Optional[str]:
181
+ """Extract a missive identifier from webhook payload by dispatching to type-specific method."""
182
+ result = self._dispatch_by_type(
183
+ "extract_%s_missive_id",
184
+ missive_type=missive_type,
185
+ payload=payload,
186
+ )
187
+
188
+ return result if isinstance(result, (str, type(None))) else None
189
+
190
+ # calculate_delivery_risk is inherited from BaseProviderCommon
191
+
192
+
193
+ __all__ = [
194
+ "BaseProviderCommon",
195
+ "BaseEmailMixin",
196
+ "BaseSMSMixin",
197
+ "BasePostalMixin",
198
+ "BaseNotificationMixin",
199
+ "BaseVoiceCallMixin",
200
+ "BaseMonitoringMixin",
201
+ "BaseBrandedMixin",
202
+ "BaseProvider",
203
+ ]
@@ -0,0 +1,166 @@
1
+ """Shared helpers for provider attachment validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, List, Optional, Tuple
6
+
7
+
8
+ class AttachmentMimeTypeMixin:
9
+ """Provide MIME-type and size validation logic for provider attachments."""
10
+
11
+ allowed_attachment_mime_types: List[str]
12
+
13
+ def _check_attachment_mime_type(
14
+ self, attachment: Any, idx: int, **kwargs: Any
15
+ ) -> Tuple[List[str], List[str]]:
16
+ """Check MIME type for a single attachment."""
17
+ errors: List[str] = []
18
+ warnings: List[str] = []
19
+
20
+ mime_type = getattr(attachment, "mime_type", None)
21
+ if mime_type:
22
+ allowed_types = kwargs.get("allowed_types", self.allowed_attachment_mime_types)
23
+ if allowed_types and mime_type not in allowed_types:
24
+ errors.append(
25
+ f"Attachment {idx + 1}: MIME type '{mime_type}' not allowed. "
26
+ f"Allowed types: {', '.join(allowed_types)}"
27
+ )
28
+ else:
29
+ warnings.append(f"Attachment {idx + 1}: MIME type not specified")
30
+
31
+ return errors, warnings
32
+
33
+ def _get_attachment_size(self, attachment: Any) -> Optional[int]:
34
+ """Get attachment size in bytes, trying multiple methods."""
35
+ size_bytes = getattr(attachment, "size_bytes", None)
36
+ if size_bytes is not None:
37
+ return size_bytes
38
+
39
+ file_obj = getattr(attachment, "file", None)
40
+ if file_obj and hasattr(file_obj, "read"):
41
+ try:
42
+ current_pos = file_obj.tell() if hasattr(file_obj, "tell") else 0
43
+ file_obj.seek(0, 2)
44
+ size_bytes = file_obj.tell() if hasattr(file_obj, "tell") else None
45
+ file_obj.seek(current_pos)
46
+ return size_bytes
47
+ except Exception:
48
+ return None
49
+ return None
50
+
51
+ def _check_attachment_size(
52
+ self, attachment: Any, idx: int, max_size_bytes: int
53
+ ) -> Tuple[Optional[int], List[str], List[str]]:
54
+ """Check file size for a single attachment."""
55
+ errors: List[str] = []
56
+ warnings: List[str] = []
57
+
58
+ size_bytes = self._get_attachment_size(attachment)
59
+ if size_bytes is not None:
60
+ try:
61
+ size_bytes = int(size_bytes)
62
+ if size_bytes > max_size_bytes:
63
+ size_mb = size_bytes / (1024 * 1024)
64
+ max_mb = max_size_bytes / (1024 * 1024)
65
+ errors.append(
66
+ f"Attachment {idx + 1}: Size {size_mb:.2f} MB exceeds maximum "
67
+ f"of {max_mb:.2f} MB"
68
+ )
69
+ return size_bytes, errors, warnings
70
+ except (ValueError, TypeError):
71
+ warnings.append(f"Attachment {idx + 1}: Invalid size_bytes value")
72
+ else:
73
+ warnings.append(f"Attachment {idx + 1}: File size not specified")
74
+
75
+ return None, errors, warnings
76
+
77
+
78
+ def aggregate_attachment_checks(
79
+ attachments: List[Any],
80
+ *,
81
+ mime_checker: Callable[[Any, int], Tuple[List[str], List[str]]],
82
+ size_checker: Callable[[Any, int], Tuple[Optional[int], List[str], List[str]]],
83
+ details_factory: Optional[Callable[[], Dict[str, Any]]] = None,
84
+ ) -> Tuple[List[str], List[str], Dict[str, Any], int]:
85
+ """Run MIME/size validations and aggregate errors, warnings, and totals."""
86
+ errors: List[str] = []
87
+ warnings: List[str] = []
88
+ details: Dict[str, Any] = (
89
+ details_factory()
90
+ if details_factory
91
+ else {"total_size_bytes": 0, "attachments_checked": 0, "attachments_valid": 0}
92
+ )
93
+ total_size_bytes = 0
94
+
95
+ for idx, attachment in enumerate(attachments):
96
+ attachment_errors: List[str] = []
97
+ attachment_warnings: List[str] = []
98
+
99
+ mime_errors, mime_warnings = mime_checker(attachment, idx)
100
+ attachment_errors.extend(mime_errors)
101
+ attachment_warnings.extend(mime_warnings)
102
+
103
+ size_bytes, size_errors, size_warnings = size_checker(attachment, idx)
104
+ attachment_errors.extend(size_errors)
105
+ attachment_warnings.extend(size_warnings)
106
+
107
+ if size_bytes is not None:
108
+ total_size_bytes += size_bytes
109
+
110
+ if attachment_errors:
111
+ errors.extend(attachment_errors)
112
+ if attachment_warnings:
113
+ warnings.extend(attachment_warnings)
114
+
115
+ details["attachments_checked"] = details.get("attachments_checked", 0) + 1
116
+ if not attachment_errors:
117
+ details["attachments_valid"] = details.get("attachments_valid", 0) + 1
118
+
119
+ details["total_size_bytes"] = total_size_bytes
120
+ return errors, warnings, details, total_size_bytes
121
+
122
+
123
+ def summarize_attachment_validation(
124
+ *,
125
+ attachments: List[Any],
126
+ mime_checker: Callable[[Any, int], Tuple[List[str], List[str]]],
127
+ size_checker: Callable[[Any, int], Tuple[Optional[int], List[str], List[str]]],
128
+ max_size_bytes: int,
129
+ max_size_mb: float,
130
+ size_error_template: str,
131
+ details_factory: Optional[Callable[[], Dict[str, Any]]] = None,
132
+ ) -> Dict[str, Any]:
133
+ """Validate attachments and build a standardized response."""
134
+ errors, warnings, details, total_size_bytes = aggregate_attachment_checks(
135
+ attachments,
136
+ mime_checker=mime_checker,
137
+ size_checker=size_checker,
138
+ details_factory=details_factory,
139
+ )
140
+
141
+ if total_size_bytes > max_size_bytes:
142
+ total_size_mb = total_size_bytes / (1024 * 1024)
143
+ errors.append(
144
+ size_error_template.format(total_mb=total_size_mb, max_mb=max_size_mb)
145
+ )
146
+
147
+ return {
148
+ "is_valid": len(errors) == 0,
149
+ "errors": errors,
150
+ "warnings": warnings,
151
+ "details": details,
152
+ }
153
+
154
+
155
+ def attachment_check_empty_result() -> Dict[str, Any]:
156
+ """Return a standardized payload when no attachments are provided."""
157
+ return {
158
+ "is_valid": True,
159
+ "errors": [],
160
+ "warnings": [],
161
+ "details": {
162
+ "total_size_bytes": 0,
163
+ "attachments_checked": 0,
164
+ "attachments_valid": 0,
165
+ },
166
+ }