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,511 @@
1
+ """Maileva provider for postal mail and registered mail."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Dict, Optional, Tuple, cast
7
+
8
+ from ..status import MissiveStatus
9
+ from .base import BaseProvider
10
+ from .base.postal_defaults import (
11
+ POSTAL_DEFAULT_MIME_TYPES,
12
+ POSTAL_ENVELOPE_LIMITS,
13
+ )
14
+
15
+
16
+ class MailevaProvider(BaseProvider):
17
+ """
18
+ Maileva provider (Docaposte/La Poste group).
19
+
20
+ Maileva is a subsidiary of Docaposte (La Poste group) offering
21
+ electronic postal mail services for businesses.
22
+
23
+ Supports:
24
+ - Postal mail (simple, registered, signature)
25
+ - Qualified and standard electronic registered letters (LRE / ERE)
26
+ - Document archiving
27
+ """
28
+
29
+ name = "Maileva"
30
+ display_name = "Maileva"
31
+ supported_types = [
32
+ "POSTAL",
33
+ "POSTAL_REGISTERED",
34
+ "POSTAL_SIGNATURE",
35
+ "LRE",
36
+ "LRE_QUALIFIED",
37
+ "ERE",
38
+ ]
39
+ # Postal simple
40
+ postal_price = 1.435
41
+ postal_page_price_black_white = 0.33
42
+ postal_page_price_color = 0.58
43
+ postal_page_price_single_sided = 0.33
44
+ postal_page_price_duplex = 0.34
45
+ postal_allowed_attachment_mime_types = list(POSTAL_DEFAULT_MIME_TYPES)
46
+ postal_allowed_page_formats = ["A4"]
47
+ postal_envelope_limits = [limit.copy() for limit in POSTAL_ENVELOPE_LIMITS]
48
+ postal_page_limit = 45
49
+ postal_color_printing_available = True
50
+ postal_duplex_printing_available = True
51
+ postal_archiving_duration = 0
52
+
53
+ # Recommended postal mail
54
+ postal_registered_price = 5.36
55
+ postal_registered_page_price_black_white = 0.33
56
+ postal_registered_page_price_color = 0.58
57
+ postal_registered_page_price_single_sided = 0.33
58
+ postal_registered_page_price_duplex = 0.34
59
+ postal_registered_allowed_attachment_mime_types = list(POSTAL_DEFAULT_MIME_TYPES)
60
+ postal_registered_allowed_page_formats = ["A4"]
61
+ postal_registered_envelope_limits = [limit.copy() for limit in POSTAL_ENVELOPE_LIMITS]
62
+ postal_registered_page_limit = 45
63
+ postal_registered_color_printing_available = True
64
+ postal_registered_duplex_printing_available = True
65
+ postal_registered_archiving_duration = 0
66
+
67
+ # Postal signature
68
+ postal_signature_price = 6.45
69
+ postal_signature_page_price_black_white = 0.33
70
+ postal_signature_page_price_color = 0.58
71
+ postal_signature_page_price_single_sided = 0.33
72
+ postal_signature_page_price_duplex = 0.34
73
+ postal_signature_allowed_attachment_mime_types = list(POSTAL_DEFAULT_MIME_TYPES)
74
+ postal_signature_allowed_page_formats = ["A4"]
75
+ postal_signature_envelope_limits = [limit.copy() for limit in POSTAL_ENVELOPE_LIMITS]
76
+ postal_signature_page_limit = 45
77
+ postal_signature_color_printing_available = True
78
+ postal_signature_duplex_printing_available = True
79
+ postal_signature_archiving_duration = 0
80
+
81
+ # LRE standard
82
+ lre_price = 3.9
83
+ lre_page_price_black_white = 0.0
84
+ lre_page_price_color = 0.0
85
+ lre_page_price_single_sided = 0.0
86
+ lre_page_price_duplex = 0.0
87
+ lre_allowed_attachment_mime_types = ["application/pdf"]
88
+ lre_allowed_page_formats: list[str] = []
89
+ lre_envelope_limits: list[dict[str, Any]] = []
90
+ lre_page_limit = 200
91
+ lre_color_printing_available = False
92
+ lre_duplex_printing_available = False
93
+ lre_archiving_duration = 3650 # 10 years
94
+
95
+ # Qualified electronic registered mail
96
+ lre_qualified_price = 6.2
97
+ lre_qualified_page_price_black_white = 0.0
98
+ lre_qualified_page_price_color = 0.0
99
+ lre_qualified_page_price_single_sided = 0.0
100
+ lre_qualified_page_price_duplex = 0.0
101
+ lre_qualified_allowed_attachment_mime_types = ["application/pdf"]
102
+ lre_qualified_allowed_page_formats: list[str] = []
103
+ lre_qualified_envelope_limits: list[dict[str, Any]] = []
104
+ lre_qualified_page_limit = 200
105
+ lre_qualified_color_printing_available = False
106
+ lre_qualified_duplex_printing_available = False
107
+ lre_qualified_archiving_duration = 3650
108
+
109
+ # Electronic registered mail
110
+ ere_price = 2.8
111
+ ere_page_price_black_white = 0.0
112
+ ere_page_price_color = 0.0
113
+ ere_page_price_single_sided = 0.0
114
+ ere_page_price_duplex = 0.0
115
+ ere_allowed_attachment_mime_types = ["application/pdf", "application/xml"]
116
+ ere_allowed_page_formats: list[str] = []
117
+ ere_envelope_limits: list[dict[str, Any]] = []
118
+ ere_page_limit = 200
119
+ ere_color_printing_available = False
120
+ ere_duplex_printing_available = False
121
+ ere_archiving_duration = 1825 # 5 years
122
+ # Geographic scopes per service family
123
+ postal_geographic_coverage = ["FR"] # Postal mail limited to France
124
+ postal_registered_geographic_coverage = ["FR"]
125
+ postal_signature_geographic_coverage = ["FR"]
126
+ lre_geographic_coverage = ["FR"] # LRE limited to France
127
+ lre_qualified_geographic_coverage = ["FR"]
128
+ ere_geographic_coverage = ["FR"] # ERE limited to France (Docaposte trust scope)
129
+ email_geographic_coverage = ["FR"]
130
+ config_keys = [
131
+ "MAILEVA_CLIENTID",
132
+ "MAILEVA_SECRET",
133
+ "MAILEVA_USERNAME",
134
+ "MAILEVA_PASSWORD",
135
+ ]
136
+ required_packages = ["requests"]
137
+ site_url = "https://www.maileva.com/"
138
+ documentation_url = "https://www.maileva.com/developpeur"
139
+ description_text = "Electronic postal mail and registered mail services"
140
+
141
+ # API endpoints
142
+ API_BASE_PRODUCTION = "https://api.maileva.com"
143
+ API_BASE_SANDBOX = "https://api.sandbox.maileva.net"
144
+ AUTH_BASE_PRODUCTION = "https://connexion.maileva.com"
145
+ AUTH_BASE_SANDBOX = "https://connexion.sandbox.maileva.net"
146
+
147
+ def _get_api_base(self) -> str:
148
+ """Get API base URL based on sandbox mode."""
149
+ sandbox = self._config.get("MAILEVA_SANDBOX", False)
150
+ return self.API_BASE_SANDBOX if sandbox else self.API_BASE_PRODUCTION
151
+
152
+ def _get_auth_base(self) -> str:
153
+ """Get authentication base URL based on sandbox mode."""
154
+ sandbox = self._config.get("MAILEVA_SANDBOX", False)
155
+ return self.AUTH_BASE_SANDBOX if sandbox else self.AUTH_BASE_PRODUCTION
156
+
157
+ def _get_access_token(self) -> Optional[str]:
158
+ """
159
+ Get OAuth access token from Maileva.
160
+
161
+ Maileva uses OAuth 2.0 with client credentials flow.
162
+ """
163
+ try:
164
+ import requests
165
+
166
+ auth_url = f"{self._get_auth_base()}/auth/realms/services/protocol/openid-connect/token"
167
+ client_id = self._config.get("MAILEVA_CLIENTID")
168
+ client_secret = self._config.get("MAILEVA_SECRET")
169
+ username = self._config.get("MAILEVA_USERNAME")
170
+ password = self._config.get("MAILEVA_PASSWORD")
171
+
172
+ if not all([client_id, client_secret, username, password]):
173
+ return None
174
+
175
+ # OAuth 2.0 client credentials + resource owner password credentials
176
+ response = requests.post(
177
+ auth_url,
178
+ data={
179
+ "grant_type": "password",
180
+ "client_id": client_id,
181
+ "client_secret": client_secret,
182
+ "username": username,
183
+ "password": password,
184
+ },
185
+ timeout=10,
186
+ )
187
+ response.raise_for_status()
188
+ token_data = cast(Dict[str, Any], response.json())
189
+ access_token = token_data.get("access_token")
190
+ return str(access_token) if isinstance(access_token, str) else None
191
+
192
+ except Exception as e:
193
+ self._create_event("error", f"Failed to get access token: {e}")
194
+ return None
195
+
196
+ def _send_postal_service(
197
+ self,
198
+ *,
199
+ service: str,
200
+ is_registered: bool = False,
201
+ requires_signature: bool = False,
202
+ **kwargs,
203
+ ) -> bool:
204
+ """Internal helper to send any postal variation."""
205
+ # Validation
206
+ is_valid, error = self.validate()
207
+ if not is_valid:
208
+ self._update_status(MissiveStatus.FAILED, error_message=error)
209
+ return False
210
+
211
+ if not self._get_missive_value("recipient_address"):
212
+ self._update_status(MissiveStatus.FAILED, error_message="Address missing")
213
+ return False
214
+
215
+ try:
216
+ access_token = self._get_access_token()
217
+ if not access_token:
218
+ self._update_status(
219
+ MissiveStatus.FAILED, error_message="Failed to authenticate"
220
+ )
221
+ return False
222
+
223
+ # TODO: Implement API call
224
+ # api_base = self._get_api_base()
225
+ # Choose API version based on service type
226
+ # if is_registered or requires_signature:
227
+ # sendings_url = f"{api_base}/registered_mail/v4/sendings"
228
+ # else:
229
+ # sendings_url = f"{api_base}/mail/v2/sendings"
230
+ #
231
+ # headers = {
232
+ # "Authorization": f"Bearer {access_token}",
233
+ # "Content-Type": "application/json",
234
+ # }
235
+ #
236
+ # recipient_address = self._get_missive_value("recipient_address", "")
237
+ # address_lines = recipient_address.split("\n") if recipient_address else []
238
+ # sending_data = {
239
+ # "sender": {...},
240
+ # "recipient": {...},
241
+ # "options": {...},
242
+ # }
243
+ # TODO: Add document upload
244
+ # TODO: Add recipient details
245
+ # TODO: Submit sending
246
+
247
+ # Simulation for now
248
+ external_id = f"mv_{getattr(self.missive, 'id', 'unknown')}"
249
+
250
+ letter_type = service.replace("_", " ")
251
+ if not letter_type:
252
+ letter_type = "postal"
253
+
254
+ self._update_status(
255
+ MissiveStatus.SENT, provider=self.name, external_id=external_id
256
+ )
257
+ self._create_event("sent", f"{letter_type} letter sent via Maileva")
258
+
259
+ return True
260
+
261
+ except Exception as e:
262
+ self._update_status(MissiveStatus.FAILED, error_message=str(e))
263
+ self._create_event("failed", str(e))
264
+ return False
265
+
266
+ def _send_electronic_registered(self, service: str, *, description: str) -> bool:
267
+ """Simulate electronic registered (LRE/ERE) sending."""
268
+ is_valid, error = self.validate()
269
+ if not is_valid:
270
+ self._update_status(MissiveStatus.FAILED, error_message=error)
271
+ return False
272
+
273
+ recipient_email = self._get_missive_value("recipient_email")
274
+ if not recipient_email:
275
+ self._update_status(
276
+ MissiveStatus.FAILED, error_message="Recipient email missing"
277
+ )
278
+ return False
279
+
280
+ try:
281
+ external_id = f"{service}_{getattr(self.missive, 'id', 'unknown')}"
282
+ self._update_status(
283
+ MissiveStatus.SENT, provider=self.name, external_id=external_id
284
+ )
285
+ self._create_event("sent", f"{description} sent via Maileva")
286
+ return True
287
+ except Exception as exc: # pragma: no cover - defensive
288
+ self._update_status(MissiveStatus.FAILED, error_message=str(exc))
289
+ self._create_event("failed", str(exc))
290
+ return False
291
+
292
+ def send_lre(self, **kwargs) -> bool:
293
+ """Send a standard LRE via Maileva."""
294
+ return self._send_electronic_registered("lre", description="LRE", **kwargs)
295
+
296
+ def send_lre_qualified(self, **kwargs) -> bool:
297
+ """Send a qualified LRE via Maileva."""
298
+ return self._send_electronic_registered(
299
+ "lre_qualified", description="Qualified LRE", **kwargs
300
+ )
301
+
302
+ def send_ere(self, **kwargs) -> bool:
303
+ """Send an electronic registered email via Maileva."""
304
+ return self._send_electronic_registered("ere", description="ERE", **kwargs)
305
+
306
+ def validate_webhook_signature(
307
+ self,
308
+ payload: Any,
309
+ headers: Dict[str, str],
310
+ *,
311
+ missive_type: Optional[str] = None,
312
+ **kwargs: Any,
313
+ ) -> Tuple[bool, str]:
314
+ """Validate Maileva webhook signature."""
315
+ # TODO: Implement according to Maileva webhook documentation
316
+ # Maileva webhooks may use HMAC or OAuth signature
317
+ return True, ""
318
+
319
+ def extract_missive_id(
320
+ self, payload: Any, *, missive_type: Optional[str] = None, **kwargs: Any
321
+ ) -> Optional[str]:
322
+ """Extract missive ID from Maileva webhook."""
323
+ if isinstance(payload, dict):
324
+ result = (
325
+ payload.get("sending_id")
326
+ or payload.get("reference")
327
+ or payload.get("id")
328
+ )
329
+ return str(result) if result else None
330
+ return None
331
+
332
+ def extract_event_type(self, payload: Any) -> str:
333
+ """Extract event type from Maileva webhook."""
334
+ if isinstance(payload, dict):
335
+ result = payload.get("status") or payload.get("event_type") or "unknown"
336
+ return str(result) if result else "unknown"
337
+ return "unknown"
338
+
339
+ def get_proofs_of_delivery(self, service_type: Optional[str] = None) -> list:
340
+ """
341
+ Get all Maileva proofs.
342
+
343
+ Maileva generates:
344
+ - Deposit proof (global_deposit_proofs)
345
+ - Delivery proof (if registered)
346
+ - Signature proof (if signature required)
347
+ """
348
+ if not self.missive:
349
+ return []
350
+
351
+ external_id = getattr(self.missive, "external_id", None)
352
+ if not external_id or not str(external_id).startswith("mv_"):
353
+ return []
354
+
355
+ try:
356
+ access_token = self._get_access_token()
357
+ if not access_token:
358
+ return []
359
+
360
+ api_base = self._get_api_base()
361
+ sending_id = str(external_id).replace("mv_", "")
362
+
363
+ # TODO: Implement real API call
364
+ # proofs_url = f"{api_base}/registered_mail/v4/global_deposit_proofs"
365
+ # headers = {"Authorization": f"Bearer {access_token}"}
366
+ # response = requests.get(
367
+ # proofs_url,
368
+ # params={"sending_id": sending_id},
369
+ # headers=headers,
370
+ # timeout=10,
371
+ # )
372
+ # response.raise_for_status()
373
+ # proofs_data = response.json()
374
+
375
+ # Simulation
376
+ clock = getattr(self, "_clock", None)
377
+ sent_at = getattr(self.missive, "sent_at", None) or (
378
+ clock() if callable(clock) else datetime.now(timezone.utc)
379
+ )
380
+
381
+ proofs = [
382
+ {
383
+ "type": "deposit_receipt",
384
+ "label": "Deposit Proof",
385
+ "available": True,
386
+ "url": f"{api_base}/registered_mail/v4/global_deposit_proofs/{sending_id}",
387
+ "generated_at": sent_at,
388
+ "expires_at": None,
389
+ "format": "pdf",
390
+ "metadata": {
391
+ "proof_type": "deposit",
392
+ "provider": "maileva",
393
+ "sending_id": sending_id,
394
+ },
395
+ }
396
+ ]
397
+
398
+ # Add delivery proof if registered
399
+ if getattr(self.missive, "is_registered", False):
400
+ delivered_at = getattr(self.missive, "delivered_at", None)
401
+ if delivered_at:
402
+ proofs.append(
403
+ {
404
+ "type": "acknowledgment_receipt",
405
+ "label": "Acknowledgement of Receipt",
406
+ "available": True,
407
+ "url": f"{api_base}/registered_mail/v4/sendings/{sending_id}/proofs/ar",
408
+ "generated_at": delivered_at,
409
+ "expires_at": None,
410
+ "format": "pdf",
411
+ "metadata": {
412
+ "proof_type": "ar",
413
+ "provider": "maileva",
414
+ "sending_id": sending_id,
415
+ },
416
+ }
417
+ )
418
+
419
+ return proofs
420
+
421
+ except Exception as e:
422
+ self._create_event("error", f"Failed to get proofs: {e}")
423
+ return []
424
+
425
+ def get_service_status(self) -> Dict:
426
+ """
427
+ Gets Maileva status and credits.
428
+
429
+ Maileva uses prepaid credits and subscription model.
430
+
431
+ Returns:
432
+ Dict with status, credits, etc.
433
+ """
434
+ return self._build_service_status_payload(
435
+ rate_limits={"per_second": 5, "per_minute": 300},
436
+ warnings=["Maileva API not fully implemented - uncomment the code"],
437
+ details={
438
+ "refill_url": "https://www.maileva.com/",
439
+ "api_docs": "https://www.maileva.com/developpeur",
440
+ "sandbox_url": "https://secure2.recette.maileva.com/",
441
+ },
442
+ sla={"uptime_percentage": 99.5},
443
+ )
444
+
445
+ def get_postal_service_info(self) -> Dict[str, Any]:
446
+ """Get postal service information."""
447
+ return {
448
+ "provider": self.name,
449
+ "services": ["postal", "postal_registered", "postal_signature"],
450
+ "max_attachment_size_mb": 10.0,
451
+ "max_attachment_size_bytes": 10 * 1024 * 1024,
452
+ "allowed_attachment_mime_types": self.postal_allowed_attachment_mime_types,
453
+ "geographic_coverage": self.postal_geographic_coverage,
454
+ "features": [
455
+ "Color printing",
456
+ "Duplex printing",
457
+ "Optional address sheet",
458
+ "Document archiving",
459
+ ],
460
+ }
461
+
462
+ def get_lre_service_info(self) -> Dict[str, Any]:
463
+ """Describe Maileva LRE (standard) features."""
464
+ return {
465
+ "provider": self.name,
466
+ "services": ["lre"],
467
+ "geographic_coverage": self.lre_geographic_coverage,
468
+ "features": [
469
+ "Electronic registered letter creation",
470
+ "Delivery tracking and proofs",
471
+ "Integration via Maileva API catalogue",
472
+ ],
473
+ "details": {
474
+ "catalog_url": "https://www.maileva.com/catalogue-api/envoi-et-suivi-ere-simples/",
475
+ },
476
+ }
477
+
478
+ def get_lre_qualified_service_info(self) -> Dict[str, Any]:
479
+ """Describe qualified LRE (Docaposte trust service) capabilities."""
480
+ return {
481
+ "provider": self.name,
482
+ "services": ["lre_qualified"],
483
+ "geographic_coverage": self.lre_qualified_geographic_coverage,
484
+ "features": [
485
+ "Qualified LRE generation (eIDAS-compliant)",
486
+ "Qualified electronic signature + timestamp",
487
+ "Full traceability with legal proofs",
488
+ ],
489
+ "details": {
490
+ "catalog_url": "https://www.maileva.com/catalogue-api/envoi-et-suivi-de-lre-qualifiees/",
491
+ },
492
+ }
493
+
494
+ def get_ere_service_info(self) -> Dict[str, Any]:
495
+ """Describe simple electronic registered email (ERE) capabilities."""
496
+ return {
497
+ "provider": self.name,
498
+ "services": ["ere"],
499
+ "geographic_coverage": self.ere_geographic_coverage,
500
+ "features": [
501
+ "Electronic registered email dispatch",
502
+ "Acknowledgement of receipt via Maileva",
503
+ "API monitoring and follow-up",
504
+ ],
505
+ "details": {
506
+ "catalog_url": "https://www.maileva.com/catalogue-api/envoi-et-suivi-ere-simples/",
507
+ },
508
+ }
509
+
510
+
511
+ __all__ = ["MailevaProvider"]
@@ -0,0 +1,118 @@
1
+ """Mailgun email provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ from typing import Any, Dict, Optional, Tuple
8
+
9
+ from .base import BaseProvider
10
+
11
+
12
+ class MailgunProvider(BaseProvider):
13
+ """Mailgun provider (Email only)."""
14
+
15
+ name = "Mailgun"
16
+ display_name = "Mailgun"
17
+ supported_types = ["EMAIL", "EMAIL_MARKETING"]
18
+ # Geographic scope and pricing
19
+ email_geographic_coverage = ["*"]
20
+ email_geo = email_geographic_coverage
21
+ email_marketing_geographic_coverage = ["*"]
22
+ email_marketing_geo = email_marketing_geographic_coverage
23
+ email_price = 0.90 # $0.80/100 emails ~ €0.009 -> scaled to €0.009, rounding
24
+ email_marketing_price = 0.12 # Marketing campaigns at slightly higher cost
25
+ email_marketing_max_attachment_size_mb = 10
26
+ email_marketing_allowed_attachment_mime_types = [
27
+ "text/html",
28
+ "image/jpeg",
29
+ "image/png",
30
+ ]
31
+ config_keys = ["MAILGUN_API_KEY", "MAILGUN_DOMAIN"]
32
+ required_packages = ["mailgun"]
33
+ site_url = "https://www.mailgun.com/"
34
+ status_url = "https://status.mailgun.com/"
35
+ documentation_url = "https://documentation.mailgun.com/"
36
+ description_text = (
37
+ "Transactional email service with advanced validation and routing"
38
+ )
39
+
40
+ def send_email(self, **kwargs) -> bool:
41
+ """Send via Mailgun API"""
42
+ return self._send_email_simulation(
43
+ prefix="mg", event_message="Email sent via Mailgun"
44
+ )
45
+
46
+ def send_email_marketing(self, **kwargs) -> bool:
47
+ """Reuse transactional pipeline for marketing blasts."""
48
+ return self.send_email(**kwargs)
49
+
50
+ def validate_webhook_signature(
51
+ self,
52
+ payload: Any,
53
+ headers: Dict[str, str],
54
+ *,
55
+ missive_type: Optional[str] = None,
56
+ **kwargs: Any,
57
+ ) -> Tuple[bool, str]:
58
+ """Validate Mailgun webhook signature."""
59
+ api_key = self._config.get("MAILGUN_API_KEY")
60
+ if not api_key:
61
+ return True, ""
62
+
63
+ signature_data = payload.get("signature", {})
64
+ timestamp = signature_data.get("timestamp", "")
65
+ token = signature_data.get("token", "")
66
+ signature = signature_data.get("signature", "")
67
+
68
+ expected_signature = hmac.new(
69
+ api_key.encode(), f"{timestamp}{token}".encode(), hashlib.sha256
70
+ ).hexdigest()
71
+
72
+ if hmac.compare_digest(signature, expected_signature):
73
+ return True, ""
74
+ return False, "Signature does not match"
75
+
76
+ def extract_email_missive_id(self, payload: Any) -> Optional[str]:
77
+ """Extract missive ID from Mailgun webhook."""
78
+ if isinstance(payload, dict):
79
+ event_data = payload.get("event-data", {})
80
+ if isinstance(event_data, dict):
81
+ user_variables = event_data.get("user-variables", {})
82
+ if isinstance(user_variables, dict):
83
+ result = user_variables.get("missive_id")
84
+ return str(result) if result else None
85
+ return None
86
+
87
+ def extract_event_type(self, payload: Any) -> str:
88
+ """Extract event type from Mailgun webhook."""
89
+ if isinstance(payload, dict):
90
+ event_data = payload.get("event-data", {})
91
+ if isinstance(event_data, dict):
92
+ result = event_data.get("event", "unknown")
93
+ return str(result) if result else "unknown"
94
+ return "unknown"
95
+
96
+ def get_service_status(self) -> Dict:
97
+ """
98
+ Gets Mailgun status and credits.
99
+
100
+ Mailgun charges per email sent.
101
+
102
+ Returns:
103
+ Dict with status, credits, etc.
104
+ """
105
+ return self._build_generic_service_status(
106
+ credits_type="emails",
107
+ credits_currency="emails",
108
+ rate_limits={"per_second": 100, "per_minute": 6000},
109
+ warnings=["Mailgun API not implemented - uncomment the code"],
110
+ details={
111
+ "status_page": "https://status.mailgun.com/",
112
+ "api_docs": "https://documentation.mailgun.com/en/latest/api-stats.html",
113
+ },
114
+ sla={"uptime_percentage": 99.99},
115
+ )
116
+
117
+
118
+ __all__ = ["MailgunProvider"]