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,249 @@
|
|
|
1
|
+
"""Certeurope provider for electronic registered letters (LRE)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from ..status import MissiveStatus
|
|
9
|
+
from .base import BaseProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CerteuropeProvider(BaseProvider):
|
|
13
|
+
"""
|
|
14
|
+
Certeurope provider (Electronic Registered Letter).
|
|
15
|
+
|
|
16
|
+
Required configuration:
|
|
17
|
+
CERTEUROPE_API_KEY: Certeurope API key
|
|
18
|
+
CERTEUROPE_API_SECRET: API Secret
|
|
19
|
+
CERTEUROPE_API_URL: API URL
|
|
20
|
+
CERTEUROPE_SENDER_EMAIL: Registered sender email
|
|
21
|
+
|
|
22
|
+
Recipient must have an email and complete postal address.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "certeurope"
|
|
26
|
+
display_name = "Certeurope (LRE)"
|
|
27
|
+
supported_types = ["LRE"]
|
|
28
|
+
lre_price = 5.5
|
|
29
|
+
lre_page_price_black_white = 0.0
|
|
30
|
+
lre_page_price_color = 0.0
|
|
31
|
+
lre_page_price_single_sided = 0.0
|
|
32
|
+
lre_page_price_duplex = 0.0
|
|
33
|
+
lre_allowed_attachment_mime_types: List[str] = ["application/pdf"]
|
|
34
|
+
lre_allowed_page_formats: List[str] = []
|
|
35
|
+
lre_envelope_limits: List[Dict[str, Any]] = []
|
|
36
|
+
lre_page_limit = 200
|
|
37
|
+
lre_color_printing_available = False
|
|
38
|
+
lre_duplex_printing_available = False
|
|
39
|
+
lre_archiving_duration = 3650
|
|
40
|
+
config_keys = [
|
|
41
|
+
"CERTEUROPE_API_KEY",
|
|
42
|
+
"CERTEUROPE_API_SECRET",
|
|
43
|
+
"CERTEUROPE_API_URL",
|
|
44
|
+
"CERTEUROPE_SENDER_EMAIL",
|
|
45
|
+
]
|
|
46
|
+
required_packages = ["requests"]
|
|
47
|
+
site_url = "https://www.certeurope.fr/"
|
|
48
|
+
description_text = "Electronic registered email with legal value (LRE)"
|
|
49
|
+
# Geographic scope
|
|
50
|
+
lre_geographic_coverage = ["Europe"]
|
|
51
|
+
|
|
52
|
+
def validate(self) -> tuple[bool, str]:
|
|
53
|
+
"""Validate that the recipient has an email and address"""
|
|
54
|
+
if not self.missive:
|
|
55
|
+
return False, "Missive not defined"
|
|
56
|
+
|
|
57
|
+
recipient = getattr(self.missive, "recipient", None)
|
|
58
|
+
if not recipient or not getattr(recipient, "email", None):
|
|
59
|
+
return False, "Recipient must have an email for Certeurope ERL"
|
|
60
|
+
|
|
61
|
+
# Check postal address (often required)
|
|
62
|
+
warnings = []
|
|
63
|
+
if not getattr(recipient, "address_line1", None):
|
|
64
|
+
warnings.append("Postal address missing")
|
|
65
|
+
if not getattr(recipient, "postal_code", None) or not getattr(
|
|
66
|
+
recipient, "city", None
|
|
67
|
+
):
|
|
68
|
+
warnings.append("Postal code and city required")
|
|
69
|
+
if not getattr(recipient, "name", None):
|
|
70
|
+
warnings.append("Recipient name required")
|
|
71
|
+
|
|
72
|
+
if warnings:
|
|
73
|
+
return False, "; ".join(warnings)
|
|
74
|
+
|
|
75
|
+
return True, ""
|
|
76
|
+
|
|
77
|
+
def send_lre(self, **kwargs) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Send an LRE via Certeurope.
|
|
80
|
+
|
|
81
|
+
TODO: Implement actual sending via Certeurope API
|
|
82
|
+
"""
|
|
83
|
+
is_valid, error = self.validate()
|
|
84
|
+
if not is_valid:
|
|
85
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
# TODO: Implement actual sending
|
|
89
|
+
# 1. Generate the letter PDF
|
|
90
|
+
# 2. Create SOAP/REST Certeurope request
|
|
91
|
+
# 3. Send the signed document
|
|
92
|
+
# 4. Retrieve the deposit certificate
|
|
93
|
+
|
|
94
|
+
missive_id = getattr(self.missive, "id", "unknown")
|
|
95
|
+
external_id = f"certeurope_sim_{missive_id}"
|
|
96
|
+
self._update_status(MissiveStatus.SENT, external_id=external_id)
|
|
97
|
+
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
def get_lre_service_info(self) -> dict[str, Any]:
|
|
101
|
+
"""Return the service descriptor for LRE deliveries."""
|
|
102
|
+
return {
|
|
103
|
+
"provider": self.name,
|
|
104
|
+
"services": ["lre"],
|
|
105
|
+
"geographic_coverage": self.lre_geographic_coverage,
|
|
106
|
+
"features": [
|
|
107
|
+
"Qualified electronic registered letters",
|
|
108
|
+
"Deposit and presentation certificates",
|
|
109
|
+
"Acknowledgement of receipt",
|
|
110
|
+
"Qualified timestamps",
|
|
111
|
+
"10-year archiving",
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def check_status(self, external_id: Optional[str] = None) -> Optional[str]:
|
|
116
|
+
"""
|
|
117
|
+
Check the LRE status (sending, reception, AR).
|
|
118
|
+
|
|
119
|
+
TODO: Implement verification via Certeurope API
|
|
120
|
+
"""
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def get_proofs_of_delivery(self, service_type: Optional[str] = None) -> list:
|
|
124
|
+
"""
|
|
125
|
+
Get all Certeurope proofs.
|
|
126
|
+
|
|
127
|
+
Certeurope generates several documents:
|
|
128
|
+
1. Deposit certificate (proof of sending)
|
|
129
|
+
2. Copy of sent document (archived)
|
|
130
|
+
3. Acknowledgement of receipt (proof of reading)
|
|
131
|
+
4. Presentation certificate (if registered)
|
|
132
|
+
5. Qualified timestamp
|
|
133
|
+
|
|
134
|
+
TODO: Implement via Certeurope API
|
|
135
|
+
"""
|
|
136
|
+
if not self.missive:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
external_id = getattr(self.missive, "external_id", None)
|
|
140
|
+
if not external_id or not str(external_id).startswith("certeurope_"):
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
# TODO: Real API call (SOAP or REST depending on version)
|
|
144
|
+
|
|
145
|
+
# Simulation
|
|
146
|
+
clock = getattr(self, "_clock", None)
|
|
147
|
+
sent_at = getattr(self.missive, "sent_at", None) or (
|
|
148
|
+
clock() if callable(clock) else datetime.now(timezone.utc)
|
|
149
|
+
)
|
|
150
|
+
expiration = sent_at + timedelta(days=3650) # 10 years
|
|
151
|
+
proofs = []
|
|
152
|
+
|
|
153
|
+
# 1. Deposit certificate (always available)
|
|
154
|
+
proofs.append(
|
|
155
|
+
{
|
|
156
|
+
"type": "deposit_certificate",
|
|
157
|
+
"label": "Deposit Certificate",
|
|
158
|
+
"available": True,
|
|
159
|
+
"url": (f"https://www.certeurope.fr/lre/deposit/{external_id}.pdf"),
|
|
160
|
+
"generated_at": sent_at,
|
|
161
|
+
"expires_at": expiration,
|
|
162
|
+
"format": "pdf",
|
|
163
|
+
"metadata": {
|
|
164
|
+
"certificate_type": "deposit",
|
|
165
|
+
"legal_value": "eIDAS probative value",
|
|
166
|
+
"provider": "certeurope",
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# 2. Signed archived document
|
|
172
|
+
proofs.append(
|
|
173
|
+
{
|
|
174
|
+
"type": "archived_document",
|
|
175
|
+
"label": "Archived Document",
|
|
176
|
+
"available": True,
|
|
177
|
+
"url": (f"https://www.certeurope.fr/lre/archive/{external_id}.pdf"),
|
|
178
|
+
"generated_at": sent_at,
|
|
179
|
+
"expires_at": expiration,
|
|
180
|
+
"format": "pdf",
|
|
181
|
+
"metadata": {
|
|
182
|
+
"document_type": "archived_signed",
|
|
183
|
+
"provider": "certeurope",
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# 3. Qualified timestamp
|
|
189
|
+
proofs.append(
|
|
190
|
+
{
|
|
191
|
+
"type": "qualified_timestamp",
|
|
192
|
+
"label": "Qualified Timestamp",
|
|
193
|
+
"available": True,
|
|
194
|
+
"url": (f"https://www.certeurope.fr/lre/timestamp/{external_id}.xml"),
|
|
195
|
+
"generated_at": sent_at,
|
|
196
|
+
"expires_at": expiration,
|
|
197
|
+
"format": "xml",
|
|
198
|
+
"metadata": {
|
|
199
|
+
"timestamp_type": "qualified_eidas",
|
|
200
|
+
"provider": "certeurope",
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# 4. Electronic AR (if read)
|
|
206
|
+
read_at = getattr(self.missive, "read_at", None)
|
|
207
|
+
if read_at:
|
|
208
|
+
proofs.append(
|
|
209
|
+
{
|
|
210
|
+
"type": "acknowledgment_receipt",
|
|
211
|
+
"label": "Acknowledgement of Receipt",
|
|
212
|
+
"available": True,
|
|
213
|
+
"url": (f"https://www.certeurope.fr/lre/ar/{external_id}.pdf"),
|
|
214
|
+
"generated_at": read_at,
|
|
215
|
+
"expires_at": expiration,
|
|
216
|
+
"format": "pdf",
|
|
217
|
+
"metadata": {
|
|
218
|
+
"certificate_type": "acknowledgment",
|
|
219
|
+
"read_date": (
|
|
220
|
+
read_at.isoformat()
|
|
221
|
+
if hasattr(read_at, "isoformat")
|
|
222
|
+
else str(read_at)
|
|
223
|
+
),
|
|
224
|
+
"provider": "certeurope",
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
proofs.append(
|
|
230
|
+
{
|
|
231
|
+
"type": "acknowledgment_receipt",
|
|
232
|
+
"label": "Acknowledgement of Receipt",
|
|
233
|
+
"available": False,
|
|
234
|
+
"url": None,
|
|
235
|
+
"generated_at": None,
|
|
236
|
+
"expires_at": None,
|
|
237
|
+
"format": "pdf",
|
|
238
|
+
"metadata": {
|
|
239
|
+
"status": "pending",
|
|
240
|
+
"message": "Waiting for read",
|
|
241
|
+
"provider": "certeurope",
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return proofs
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
__all__ = ["CerteuropeProvider"]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Local email provider emulating Django's email backend without Django."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import smtplib
|
|
6
|
+
from email.message import EmailMessage
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Tuple
|
|
9
|
+
|
|
10
|
+
from ..status import MissiveStatus
|
|
11
|
+
from .base import BaseProvider
|
|
12
|
+
from .base.email_message import build_email_message
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DjangoEmailProvider(BaseProvider):
|
|
16
|
+
"""Minimal SMTP/file-based email provider compatible with Django configs."""
|
|
17
|
+
|
|
18
|
+
name = "django_email"
|
|
19
|
+
display_name = "Django Email Backend"
|
|
20
|
+
supported_types = ["EMAIL", "EMAIL_MARKETING"]
|
|
21
|
+
email_geographic_coverage: List[str] | str = ["*"]
|
|
22
|
+
email_geo: Any = email_geographic_coverage
|
|
23
|
+
email_marketing_geographic_coverage: List[str] | str = ["*"]
|
|
24
|
+
email_marketing_geo = email_marketing_geographic_coverage
|
|
25
|
+
description_text = (
|
|
26
|
+
"Lightweight email provider delegating to SMTP or local file delivery. "
|
|
27
|
+
"Mimics Django's console/backend behaviour without importing Django."
|
|
28
|
+
)
|
|
29
|
+
required_packages: List[str] = []
|
|
30
|
+
|
|
31
|
+
def validate(self) -> Tuple[bool, str]:
|
|
32
|
+
"""Ensure minimal configuration is present."""
|
|
33
|
+
# Inject default geo config if not present so BaseProviderCommon validation passes
|
|
34
|
+
if "email_geo" not in self._raw_config:
|
|
35
|
+
self._raw_config["email_geo"] = self.email_geo
|
|
36
|
+
|
|
37
|
+
if not self._raw_config.get("DEFAULT_FROM_EMAIL"):
|
|
38
|
+
sender_name = self._get_missive_value("sender") or "noreply@example.com"
|
|
39
|
+
self._raw_config["DEFAULT_FROM_EMAIL"] = (
|
|
40
|
+
sender_name if isinstance(sender_name, str) else "noreply@example.com"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if not any(
|
|
44
|
+
[
|
|
45
|
+
self._raw_config.get("EMAIL_FILE_PATH"),
|
|
46
|
+
self._raw_config.get("EMAIL_HOST"),
|
|
47
|
+
self._bool_config("EMAIL_SUPPRESS_SEND", False),
|
|
48
|
+
]
|
|
49
|
+
):
|
|
50
|
+
return (
|
|
51
|
+
False,
|
|
52
|
+
"Configure EMAIL_HOST/EMAIL_PORT, EMAIL_FILE_PATH, "
|
|
53
|
+
"or set EMAIL_SUPPRESS_SEND to true to record emails locally.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return super().validate()
|
|
57
|
+
|
|
58
|
+
def send_email(self, **kwargs: Any) -> bool:
|
|
59
|
+
"""Send email via SMTP or write it locally, similar to Django's backend."""
|
|
60
|
+
is_valid, error = self._validate_and_check_recipient(
|
|
61
|
+
"get_recipient_email", "Recipient email missing"
|
|
62
|
+
)
|
|
63
|
+
if not is_valid:
|
|
64
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
recipient = self._get_missive_value(
|
|
68
|
+
"get_recipient_email"
|
|
69
|
+
) or self._get_missive_value("recipient_email")
|
|
70
|
+
|
|
71
|
+
if not recipient:
|
|
72
|
+
self._update_status(
|
|
73
|
+
MissiveStatus.FAILED, error_message="Recipient email missing"
|
|
74
|
+
)
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
message = self._build_email_message(recipient)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
delivery_target = self._deliver(message)
|
|
81
|
+
except (smtplib.SMTPException, OSError, ValueError) as exc:
|
|
82
|
+
return self._handle_send_error(exc)
|
|
83
|
+
|
|
84
|
+
external_id = f"django_email_{getattr(self.missive, 'id', 'unknown')}"
|
|
85
|
+
self._update_status(
|
|
86
|
+
MissiveStatus.SENT, provider=self.name, external_id=external_id
|
|
87
|
+
)
|
|
88
|
+
self._create_event("sent", f"Email dispatched via {delivery_target}")
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def send_email_marketing(self, **kwargs: Any) -> bool:
|
|
92
|
+
"""Marketing emails reuse the same simple pipeline."""
|
|
93
|
+
return self.send_email(**kwargs)
|
|
94
|
+
|
|
95
|
+
def get_email_service_info(self) -> Dict[str, Any]:
|
|
96
|
+
host = self._raw_config.get("EMAIL_HOST") or "localhost"
|
|
97
|
+
return {
|
|
98
|
+
"credits": None,
|
|
99
|
+
"credits_type": "unlimited",
|
|
100
|
+
"is_available": True,
|
|
101
|
+
"limits": {"max_attachment_mb": self.max_email_attachment_size_mb},
|
|
102
|
+
"warnings": [],
|
|
103
|
+
"details": {
|
|
104
|
+
"backend": "smtp" if self._raw_config.get("EMAIL_HOST") else "file",
|
|
105
|
+
"host": host,
|
|
106
|
+
"port": self._raw_config.get("EMAIL_PORT"),
|
|
107
|
+
"use_tls": self._bool_config("EMAIL_USE_TLS", False),
|
|
108
|
+
"use_ssl": self._bool_config("EMAIL_USE_SSL", False),
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def get_email_marketing_service_info(self) -> Dict[str, Any]:
|
|
113
|
+
return self.get_email_service_info()
|
|
114
|
+
|
|
115
|
+
def get_service_status(self) -> Dict[str, Any]:
|
|
116
|
+
"""Return lightweight status for monitoring screens."""
|
|
117
|
+
backend = "smtp" if self._raw_config.get("EMAIL_HOST") else "file"
|
|
118
|
+
return self._build_generic_service_status(
|
|
119
|
+
status="operational",
|
|
120
|
+
is_available=True,
|
|
121
|
+
credits_type="unlimited",
|
|
122
|
+
rate_limits={},
|
|
123
|
+
details={"backend": backend},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Internal helpers
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
def _build_email_message(self, recipient: str) -> EmailMessage:
|
|
130
|
+
from_email = str(
|
|
131
|
+
self._raw_config.get("DEFAULT_FROM_EMAIL") or "noreply@example.com"
|
|
132
|
+
)
|
|
133
|
+
return build_email_message(self, recipient, from_email=from_email)
|
|
134
|
+
|
|
135
|
+
def _deliver(self, message: EmailMessage) -> str:
|
|
136
|
+
if self._bool_config("EMAIL_SUPPRESS_SEND", False):
|
|
137
|
+
path = self._persist_to_file(message)
|
|
138
|
+
return f"local file (suppressed) -> {path}"
|
|
139
|
+
|
|
140
|
+
file_path = self._raw_config.get("EMAIL_FILE_PATH")
|
|
141
|
+
if file_path:
|
|
142
|
+
path = self._persist_to_file(message)
|
|
143
|
+
return f"local file -> {path}"
|
|
144
|
+
|
|
145
|
+
return self._send_via_smtp(message)
|
|
146
|
+
|
|
147
|
+
def _persist_to_file(self, message: EmailMessage) -> str:
|
|
148
|
+
directory = Path(self._raw_config.get("EMAIL_FILE_PATH") or "./sent-emails")
|
|
149
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
timestamp = self._clock().strftime("%Y%m%d-%H%M%S")
|
|
151
|
+
missive_id = getattr(self.missive, "id", "unknown")
|
|
152
|
+
filename = f"{timestamp}_{missive_id}.eml"
|
|
153
|
+
target = directory / filename
|
|
154
|
+
target.write_text(message.as_string(), encoding="utf-8")
|
|
155
|
+
return str(target)
|
|
156
|
+
|
|
157
|
+
def _send_via_smtp(self, message: EmailMessage) -> str:
|
|
158
|
+
host = self._raw_config.get("EMAIL_HOST") or "localhost"
|
|
159
|
+
port = int(self._raw_config.get("EMAIL_PORT") or 25)
|
|
160
|
+
use_ssl = self._bool_config("EMAIL_USE_SSL", False)
|
|
161
|
+
use_tls = self._bool_config("EMAIL_USE_TLS", not use_ssl)
|
|
162
|
+
timeout = float(self._raw_config.get("EMAIL_TIMEOUT") or 10)
|
|
163
|
+
|
|
164
|
+
smtp_class = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP
|
|
165
|
+
with smtp_class(host, port, timeout=timeout) as client:
|
|
166
|
+
if not use_ssl and use_tls:
|
|
167
|
+
client.starttls()
|
|
168
|
+
username = self._raw_config.get("EMAIL_HOST_USER")
|
|
169
|
+
password = self._raw_config.get("EMAIL_HOST_PASSWORD")
|
|
170
|
+
if username and password:
|
|
171
|
+
client.login(username, password)
|
|
172
|
+
client.send_message(message)
|
|
173
|
+
return f"smtp://{host}:{port}"
|
|
174
|
+
|
|
175
|
+
def _bool_config(self, key: str, default: bool) -> bool:
|
|
176
|
+
value = self._raw_config.get(key, default)
|
|
177
|
+
if isinstance(value, str):
|
|
178
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
179
|
+
return bool(value)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = ["DjangoEmailProvider"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Firebase Cloud Messaging provider for push notifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..status import MissiveStatus
|
|
8
|
+
from .base import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FCMProvider(BaseProvider):
|
|
12
|
+
"""
|
|
13
|
+
Firebase Cloud Messaging provider (push notifications).
|
|
14
|
+
|
|
15
|
+
Required configuration:
|
|
16
|
+
FCM_SERVER_KEY: Firebase server key
|
|
17
|
+
or
|
|
18
|
+
FCM_SERVICE_ACCOUNT_JSON: Path to service account JSON file
|
|
19
|
+
|
|
20
|
+
Recipient must have an FCM device_token stored in metadata.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name = "fcm"
|
|
24
|
+
display_name = "Firebase Cloud Messaging"
|
|
25
|
+
supported_types = ["PUSH_NOTIFICATION"]
|
|
26
|
+
# Geographic scope
|
|
27
|
+
push_notification_geo = "*"
|
|
28
|
+
config_keys = ["FCM_SERVER_KEY"]
|
|
29
|
+
required_packages = ["firebase-admin"]
|
|
30
|
+
site_url = "https://firebase.google.com/products/cloud-messaging"
|
|
31
|
+
description_text = "Mobile push notifications for Android and iOS (Google Firebase)"
|
|
32
|
+
|
|
33
|
+
def validate(self) -> tuple[bool, str]:
|
|
34
|
+
"""Validate that the recipient has an FCM device token"""
|
|
35
|
+
if not self.missive:
|
|
36
|
+
return False, "Missive not defined"
|
|
37
|
+
|
|
38
|
+
recipient = getattr(self.missive, "recipient", None)
|
|
39
|
+
if not recipient:
|
|
40
|
+
return False, "Recipient not defined"
|
|
41
|
+
|
|
42
|
+
metadata = getattr(recipient, "metadata", None) or {}
|
|
43
|
+
device_token = metadata.get("fcm_device_token")
|
|
44
|
+
if not device_token:
|
|
45
|
+
return (
|
|
46
|
+
False,
|
|
47
|
+
"Recipient does not have an FCM device token (add to metadata)",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return True, ""
|
|
51
|
+
|
|
52
|
+
def send_push_notification(self, **kwargs) -> bool:
|
|
53
|
+
"""
|
|
54
|
+
Send a push notification via FCM.
|
|
55
|
+
|
|
56
|
+
TODO: Implement actual sending via firebase-admin SDK:
|
|
57
|
+
from firebase_admin import messaging
|
|
58
|
+
"""
|
|
59
|
+
is_valid, error = self.validate()
|
|
60
|
+
if not is_valid:
|
|
61
|
+
self._update_status(MissiveStatus.FAILED, error_message=error)
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
# TODO: Implement actual sending
|
|
65
|
+
# message = messaging.Message(
|
|
66
|
+
# notification=messaging.Notification(
|
|
67
|
+
# title=self.missive.subject,
|
|
68
|
+
# body=self.missive.body_text or self.missive.body[:100],
|
|
69
|
+
# ),
|
|
70
|
+
# token=device_token,
|
|
71
|
+
# )
|
|
72
|
+
# response = messaging.send(message)
|
|
73
|
+
|
|
74
|
+
external_id = f"fcm_sim_{getattr(self.missive, 'id', 'unknown')}"
|
|
75
|
+
self._update_status(
|
|
76
|
+
MissiveStatus.SENT,
|
|
77
|
+
external_id=external_id,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def check_status(self, external_id: Optional[str] = None) -> Optional[str]:
|
|
83
|
+
"""
|
|
84
|
+
Check delivery status.
|
|
85
|
+
|
|
86
|
+
Note: FCM provides callbacks via webhooks.
|
|
87
|
+
"""
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = ["FCMProvider"]
|