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,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
|
+
}
|