swiftpy-framework 0.1.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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import logging
|
|
6
|
+
import ssl
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
from swiftpy.mail.envelope import Envelope
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _AiosmtplibModule(Protocol):
|
|
14
|
+
@property
|
|
15
|
+
def SMTP(self) -> Callable[..., Any]: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
aiosmtplib: _AiosmtplibModule | None
|
|
19
|
+
_AioAuthError: type[Exception]
|
|
20
|
+
_AioConnectError: type[Exception]
|
|
21
|
+
_AioSMTPException: type[Exception]
|
|
22
|
+
_AioRecipientsRefused: type[Exception]
|
|
23
|
+
_AioTimeoutError: type[Exception]
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import aiosmtplib as _aiosmtplib
|
|
27
|
+
from aiosmtplib.errors import (
|
|
28
|
+
SMTPAuthenticationError as _ImportedAioAuthError,
|
|
29
|
+
)
|
|
30
|
+
from aiosmtplib.errors import (
|
|
31
|
+
SMTPConnectError as _ImportedAioConnectError,
|
|
32
|
+
)
|
|
33
|
+
from aiosmtplib.errors import (
|
|
34
|
+
SMTPException as _ImportedAioSMTPException,
|
|
35
|
+
)
|
|
36
|
+
from aiosmtplib.errors import (
|
|
37
|
+
SMTPRecipientsRefused as _ImportedAioRecipientsRefused,
|
|
38
|
+
)
|
|
39
|
+
from aiosmtplib.errors import (
|
|
40
|
+
SMTPTimeoutError as _ImportedAioTimeoutError,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
aiosmtplib = _aiosmtplib
|
|
44
|
+
_AioAuthError = _ImportedAioAuthError
|
|
45
|
+
_AioConnectError = _ImportedAioConnectError
|
|
46
|
+
_AioSMTPException = _ImportedAioSMTPException
|
|
47
|
+
_AioRecipientsRefused = _ImportedAioRecipientsRefused
|
|
48
|
+
_AioTimeoutError = _ImportedAioTimeoutError
|
|
49
|
+
except ImportError:
|
|
50
|
+
aiosmtplib = None
|
|
51
|
+
_AioAuthError = Exception
|
|
52
|
+
_AioConnectError = Exception
|
|
53
|
+
_AioSMTPException = Exception
|
|
54
|
+
_AioRecipientsRefused = Exception
|
|
55
|
+
_AioTimeoutError = Exception
|
|
56
|
+
from swiftpy.mail.exceptions import (
|
|
57
|
+
MailAuthenticationError,
|
|
58
|
+
MailConnectionError,
|
|
59
|
+
MailDeliveryError,
|
|
60
|
+
MailTimeoutError,
|
|
61
|
+
MailTransportError,
|
|
62
|
+
)
|
|
63
|
+
from swiftpy.mail.message import MailMessage, parse_mail_address
|
|
64
|
+
from swiftpy.mail.models import MailSendResult
|
|
65
|
+
from swiftpy.mail.transports.base import MailTransport
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger("swiftpy.mail.smtp")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SMTPTransport(MailTransport):
|
|
71
|
+
"""
|
|
72
|
+
Asynchronous SMTP mail transport supporting STARTTLS, implicit TLS, and authentication.
|
|
73
|
+
Supports aiosmtplib when installed, or native asyncio RFC 5321 streaming.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
host: str = "127.0.0.1",
|
|
79
|
+
port: int = 587,
|
|
80
|
+
username: str | None = None,
|
|
81
|
+
password: str | None = None,
|
|
82
|
+
encryption: str | None = "starttls",
|
|
83
|
+
timeout: float = 30.0,
|
|
84
|
+
validate_certs: bool = True,
|
|
85
|
+
default_from: tuple[str, str | None] | None = None,
|
|
86
|
+
use_native: bool | None = None,
|
|
87
|
+
max_attachment_size_bytes: int | None = None,
|
|
88
|
+
max_message_size_bytes: int | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self.host = host
|
|
91
|
+
self.port = port
|
|
92
|
+
self.username = username
|
|
93
|
+
self.password = password
|
|
94
|
+
self.encryption = (encryption or "").lower() if encryption else None
|
|
95
|
+
self.timeout = timeout
|
|
96
|
+
self.validate_certs = validate_certs
|
|
97
|
+
self.default_from = default_from
|
|
98
|
+
self.use_native = use_native if use_native is not None else (aiosmtplib is None)
|
|
99
|
+
self.max_attachment_size_bytes = max_attachment_size_bytes
|
|
100
|
+
self.max_message_size_bytes = max_message_size_bytes
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_config(
|
|
104
|
+
cls,
|
|
105
|
+
config: dict[str, Any],
|
|
106
|
+
default_from: tuple[str, str | None] | None = None,
|
|
107
|
+
max_attachment_size_bytes: int | None = None,
|
|
108
|
+
max_message_size_bytes: int | None = None,
|
|
109
|
+
) -> SMTPTransport:
|
|
110
|
+
raw_att = config.get("max_attachment_size_bytes", max_attachment_size_bytes)
|
|
111
|
+
raw_msg = config.get("max_message_size_bytes", max_message_size_bytes)
|
|
112
|
+
return cls(
|
|
113
|
+
host=str(config.get("host", "127.0.0.1")),
|
|
114
|
+
port=int(config.get("port", 587)),
|
|
115
|
+
username=config.get("username"),
|
|
116
|
+
password=config.get("password"),
|
|
117
|
+
encryption=config.get("encryption", "starttls"),
|
|
118
|
+
timeout=float(config.get("timeout", 30.0)),
|
|
119
|
+
validate_certs=bool(config.get("validate_certs", True)),
|
|
120
|
+
default_from=default_from,
|
|
121
|
+
use_native=config.get("use_native"),
|
|
122
|
+
max_attachment_size_bytes=int(raw_att) if raw_att is not None else None,
|
|
123
|
+
max_message_size_bytes=int(raw_msg) if raw_msg is not None else None,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _create_ssl_context(self) -> ssl.SSLContext:
|
|
127
|
+
"""Create a secure SSL context for TLS/STARTTLS verification."""
|
|
128
|
+
if self.validate_certs:
|
|
129
|
+
context = ssl.create_default_context()
|
|
130
|
+
else:
|
|
131
|
+
context = ssl.create_default_context()
|
|
132
|
+
context.check_hostname = False
|
|
133
|
+
context.verify_mode = ssl.CERT_NONE
|
|
134
|
+
return context
|
|
135
|
+
|
|
136
|
+
async def send(self, message: MailMessage) -> MailSendResult:
|
|
137
|
+
if self.use_native or aiosmtplib is None:
|
|
138
|
+
return await self._send_native(message)
|
|
139
|
+
|
|
140
|
+
return await self._send_aiosmtplib(message)
|
|
141
|
+
|
|
142
|
+
async def _send_aiosmtplib(self, message: MailMessage) -> MailSendResult:
|
|
143
|
+
assert aiosmtplib is not None
|
|
144
|
+
|
|
145
|
+
email_msg = await Envelope.build(
|
|
146
|
+
message,
|
|
147
|
+
default_from=self.default_from,
|
|
148
|
+
max_message_size_bytes=self.max_message_size_bytes,
|
|
149
|
+
max_attachment_size_bytes=self.max_attachment_size_bytes,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
use_tls = self.encryption in ("tls", "ssl")
|
|
153
|
+
start_tls = self.encryption == "starttls"
|
|
154
|
+
|
|
155
|
+
tls_context = self._create_ssl_context() if (use_tls or start_tls) else None
|
|
156
|
+
|
|
157
|
+
client = aiosmtplib.SMTP(
|
|
158
|
+
hostname=self.host,
|
|
159
|
+
port=self.port,
|
|
160
|
+
use_tls=use_tls,
|
|
161
|
+
start_tls=start_tls,
|
|
162
|
+
tls_context=tls_context,
|
|
163
|
+
timeout=self.timeout,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
all_recipients = message.all_recipient_addresses()
|
|
167
|
+
from_header = str(email_msg["From"])
|
|
168
|
+
_, sender_addr = parse_mail_address(from_header, field_name="from_address")
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
async with client:
|
|
172
|
+
if self.username and self.password:
|
|
173
|
+
await client.login(self.username, self.password)
|
|
174
|
+
|
|
175
|
+
resp = await client.send_message(
|
|
176
|
+
email_msg,
|
|
177
|
+
sender=sender_addr,
|
|
178
|
+
recipients=all_recipients,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
rejected: list[str] = []
|
|
182
|
+
accepted: list[str] = list(all_recipients)
|
|
183
|
+
message_id = str(email_msg.get("Message-ID", ""))
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
isinstance(resp, tuple)
|
|
187
|
+
and len(resp) >= 2
|
|
188
|
+
and isinstance(resp[0], dict)
|
|
189
|
+
):
|
|
190
|
+
errors_dict, status_msg = resp[0], resp[1]
|
|
191
|
+
for rec in errors_dict:
|
|
192
|
+
rejected.append(rec)
|
|
193
|
+
if rec in accepted:
|
|
194
|
+
accepted.remove(rec)
|
|
195
|
+
status_text = status_msg
|
|
196
|
+
else:
|
|
197
|
+
status_text = str(resp)
|
|
198
|
+
|
|
199
|
+
if not accepted and rejected:
|
|
200
|
+
raise MailDeliveryError(
|
|
201
|
+
f"All recipients were refused by SMTP server: {rejected}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return MailSendResult(
|
|
205
|
+
success=True,
|
|
206
|
+
message_id=message_id,
|
|
207
|
+
provider="smtp",
|
|
208
|
+
accepted=accepted,
|
|
209
|
+
rejected=rejected,
|
|
210
|
+
raw_response=status_text,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
except _AioAuthError as exc:
|
|
214
|
+
raise MailAuthenticationError(f"SMTP authentication failed: {exc}") from exc
|
|
215
|
+
except _AioTimeoutError as exc:
|
|
216
|
+
raise MailTimeoutError(
|
|
217
|
+
f"SMTP connection/transmission timed out: {exc}"
|
|
218
|
+
) from exc
|
|
219
|
+
except _AioConnectError as exc:
|
|
220
|
+
raise MailConnectionError(
|
|
221
|
+
f"Failed to connect to SMTP server {self.host}:{self.port}: {exc}"
|
|
222
|
+
) from exc
|
|
223
|
+
except _AioRecipientsRefused as exc:
|
|
224
|
+
raise MailDeliveryError(f"SMTP server refused recipients: {exc}") from exc
|
|
225
|
+
except _AioSMTPException as exc:
|
|
226
|
+
raise MailTransportError(f"SMTP delivery error: {exc}") from exc
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
if isinstance(
|
|
229
|
+
exc,
|
|
230
|
+
(
|
|
231
|
+
MailAuthenticationError,
|
|
232
|
+
MailConnectionError,
|
|
233
|
+
MailTimeoutError,
|
|
234
|
+
MailDeliveryError,
|
|
235
|
+
MailTransportError,
|
|
236
|
+
),
|
|
237
|
+
):
|
|
238
|
+
raise
|
|
239
|
+
raise MailTransportError(
|
|
240
|
+
f"Unexpected error during SMTP delivery: {exc}"
|
|
241
|
+
) from exc
|
|
242
|
+
|
|
243
|
+
async def _send_native(self, message: MailMessage) -> MailSendResult:
|
|
244
|
+
"""Pure-Python asynchronous RFC 5321 SMTP implementation using asyncio streams."""
|
|
245
|
+
email_msg = await Envelope.build(
|
|
246
|
+
message,
|
|
247
|
+
default_from=self.default_from,
|
|
248
|
+
max_message_size_bytes=self.max_message_size_bytes,
|
|
249
|
+
max_attachment_size_bytes=self.max_attachment_size_bytes,
|
|
250
|
+
)
|
|
251
|
+
all_recipients = message.all_recipient_addresses()
|
|
252
|
+
from_header = str(email_msg["From"])
|
|
253
|
+
_, sender_addr = parse_mail_address(from_header, field_name="from_address")
|
|
254
|
+
|
|
255
|
+
use_tls = self.encryption in ("tls", "ssl")
|
|
256
|
+
start_tls = self.encryption == "starttls"
|
|
257
|
+
ssl_ctx = self._create_ssl_context() if (use_tls or start_tls) else None
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
reader, current_writer = await asyncio.wait_for(
|
|
261
|
+
asyncio.open_connection(
|
|
262
|
+
self.host,
|
|
263
|
+
self.port,
|
|
264
|
+
ssl=ssl_ctx if use_tls else None,
|
|
265
|
+
),
|
|
266
|
+
timeout=self.timeout,
|
|
267
|
+
)
|
|
268
|
+
_kept_plain_writer = current_writer
|
|
269
|
+
except TimeoutError as exc:
|
|
270
|
+
raise MailTimeoutError(
|
|
271
|
+
f"Connection to SMTP server {self.host}:{self.port} timed out"
|
|
272
|
+
) from exc
|
|
273
|
+
except (ConnectionRefusedError, OSError) as exc:
|
|
274
|
+
raise MailConnectionError(
|
|
275
|
+
f"Failed to connect to SMTP server {self.host}:{self.port}: {exc}"
|
|
276
|
+
) from exc
|
|
277
|
+
|
|
278
|
+
async def _read_reply() -> tuple[int, str]:
|
|
279
|
+
lines: list[str] = []
|
|
280
|
+
while True:
|
|
281
|
+
line_bytes = await asyncio.wait_for(
|
|
282
|
+
reader.readline(), timeout=self.timeout
|
|
283
|
+
)
|
|
284
|
+
if not line_bytes:
|
|
285
|
+
raise MailConnectionError(
|
|
286
|
+
"SMTP server closed connection unexpectedly"
|
|
287
|
+
)
|
|
288
|
+
line_str = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
289
|
+
lines.append(line_str)
|
|
290
|
+
if len(line_str) < 4 or line_str[3] != "-":
|
|
291
|
+
break
|
|
292
|
+
try:
|
|
293
|
+
code = int(lines[-1][:3])
|
|
294
|
+
except ValueError:
|
|
295
|
+
code = -1
|
|
296
|
+
return code, "\n".join(lines)
|
|
297
|
+
|
|
298
|
+
async def _send_cmd(cmd: str) -> tuple[int, str]:
|
|
299
|
+
current_writer.write(f"{cmd}\r\n".encode())
|
|
300
|
+
await current_writer.drain()
|
|
301
|
+
return await _read_reply()
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# 1. Greeting
|
|
305
|
+
greet_code, greet_msg = await _read_reply()
|
|
306
|
+
if greet_code != 220:
|
|
307
|
+
raise MailConnectionError(
|
|
308
|
+
f"SMTP greeting failed with code {greet_code}: {greet_msg}"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# 2. EHLO
|
|
312
|
+
code, _ehlo_resp = await _send_cmd("EHLO swiftpy.local")
|
|
313
|
+
if code != 250:
|
|
314
|
+
code, helo_resp = await _send_cmd("HELO swiftpy.local")
|
|
315
|
+
if code != 250:
|
|
316
|
+
raise MailTransportError(
|
|
317
|
+
f"EHLO/HELO rejected with code {code}: {helo_resp}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# 3. STARTTLS
|
|
321
|
+
if start_tls and ssl_ctx is not None:
|
|
322
|
+
code, stls_resp = await _send_cmd("STARTTLS")
|
|
323
|
+
if code != 220:
|
|
324
|
+
raise MailTransportError(
|
|
325
|
+
f"STARTTLS rejected with code {code}: {stls_resp}"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
loop = asyncio.get_running_loop()
|
|
329
|
+
protocol = current_writer.transport.get_protocol()
|
|
330
|
+
new_transport = await loop.start_tls(
|
|
331
|
+
current_writer.transport,
|
|
332
|
+
protocol,
|
|
333
|
+
ssl_ctx,
|
|
334
|
+
server_hostname=self.host if self.validate_certs else None,
|
|
335
|
+
)
|
|
336
|
+
if not isinstance(new_transport, asyncio.WriteTransport):
|
|
337
|
+
raise MailTransportError(
|
|
338
|
+
"Stream transport unavailable after TLS upgrade"
|
|
339
|
+
)
|
|
340
|
+
current_writer = asyncio.StreamWriter(
|
|
341
|
+
new_transport, protocol, reader, loop
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
code, ehlo_post_tls = await _send_cmd("EHLO swiftpy.local")
|
|
345
|
+
if code != 250:
|
|
346
|
+
raise MailTransportError(
|
|
347
|
+
f"EHLO after STARTTLS rejected with code {code}: {ehlo_post_tls}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# 4. Authentication
|
|
351
|
+
if self.username and self.password:
|
|
352
|
+
auth_str = f"\0{self.username}\0{self.password}".encode()
|
|
353
|
+
auth_b64 = base64.b64encode(auth_str).decode("ascii")
|
|
354
|
+
code, auth_resp = await _send_cmd(f"AUTH PLAIN {auth_b64}")
|
|
355
|
+
if code == 535 or (code >= 500 and code not in (500, 501, 502, 504)):
|
|
356
|
+
raise MailAuthenticationError(
|
|
357
|
+
f"SMTP authentication failed ({code}): {auth_resp}"
|
|
358
|
+
)
|
|
359
|
+
if code != 235:
|
|
360
|
+
# Fallback to AUTH LOGIN
|
|
361
|
+
code, _ = await _send_cmd("AUTH LOGIN")
|
|
362
|
+
if code == 334:
|
|
363
|
+
u_b64 = base64.b64encode(self.username.encode("utf-8")).decode(
|
|
364
|
+
"ascii"
|
|
365
|
+
)
|
|
366
|
+
p_b64 = base64.b64encode(self.password.encode("utf-8")).decode(
|
|
367
|
+
"ascii"
|
|
368
|
+
)
|
|
369
|
+
await _send_cmd(u_b64)
|
|
370
|
+
code, auth_resp = await _send_cmd(p_b64)
|
|
371
|
+
if code != 235:
|
|
372
|
+
raise MailAuthenticationError(
|
|
373
|
+
f"SMTP authentication failed ({code}): {auth_resp}"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# 5. MAIL FROM
|
|
377
|
+
from_code, from_resp = await _send_cmd(f"MAIL FROM:<{sender_addr}>")
|
|
378
|
+
if from_code not in (250, 200):
|
|
379
|
+
if from_code in (450, 451, 550, 554):
|
|
380
|
+
raise MailDeliveryError(
|
|
381
|
+
f"Sender refused ({from_code}): {from_resp}"
|
|
382
|
+
)
|
|
383
|
+
raise MailTransportError(
|
|
384
|
+
f"MAIL FROM refused ({from_code}): {from_resp}"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# 6. RCPT TO
|
|
388
|
+
accepted: list[str] = []
|
|
389
|
+
rejected: list[str] = []
|
|
390
|
+
for rcpt in all_recipients:
|
|
391
|
+
rcpt_code, _rcpt_resp = await _send_cmd(f"RCPT TO:<{rcpt}>")
|
|
392
|
+
if rcpt_code in (250, 251):
|
|
393
|
+
accepted.append(rcpt)
|
|
394
|
+
else:
|
|
395
|
+
rejected.append(rcpt)
|
|
396
|
+
|
|
397
|
+
if not accepted:
|
|
398
|
+
raise MailDeliveryError(
|
|
399
|
+
f"All recipients were refused by SMTP server: {rejected}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# 7. DATA
|
|
403
|
+
data_code, data_resp = await _send_cmd("DATA")
|
|
404
|
+
if data_code != 354:
|
|
405
|
+
if data_code in (450, 451, 550, 554):
|
|
406
|
+
raise MailDeliveryError(
|
|
407
|
+
f"DATA command refused ({data_code}): {data_resp}"
|
|
408
|
+
)
|
|
409
|
+
raise MailTransportError(
|
|
410
|
+
f"DATA command rejected ({data_code}): {data_resp}"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# 8. Transmit message with RFC 5321 dot-stuffing
|
|
414
|
+
raw_bytes = email_msg.as_bytes()
|
|
415
|
+
normalized_bytes = raw_bytes.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
416
|
+
if normalized_bytes.endswith(b"\n"):
|
|
417
|
+
normalized_bytes = normalized_bytes[:-1]
|
|
418
|
+
stuffed_lines = [
|
|
419
|
+
b"." + line if line.startswith(b".") else line
|
|
420
|
+
for line in normalized_bytes.split(b"\n")
|
|
421
|
+
]
|
|
422
|
+
body_payload = b"\r\n".join(stuffed_lines) + b"\r\n.\r\n"
|
|
423
|
+
current_writer.write(body_payload)
|
|
424
|
+
await current_writer.drain()
|
|
425
|
+
|
|
426
|
+
msg_code, msg_resp = await _read_reply()
|
|
427
|
+
if msg_code not in (250, 200):
|
|
428
|
+
if msg_code in (450, 451, 550, 554):
|
|
429
|
+
raise MailDeliveryError(
|
|
430
|
+
f"Message delivery refused ({msg_code}): {msg_resp}"
|
|
431
|
+
)
|
|
432
|
+
raise MailTransportError(
|
|
433
|
+
f"Message delivery rejected ({msg_code}): {msg_resp}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# 9. QUIT
|
|
437
|
+
try:
|
|
438
|
+
await _send_cmd("QUIT")
|
|
439
|
+
except (OSError, TimeoutError):
|
|
440
|
+
logger.debug(
|
|
441
|
+
"Failed to cleanly send QUIT to SMTP server", exc_info=True
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return MailSendResult(
|
|
445
|
+
success=True,
|
|
446
|
+
message_id=str(email_msg.get("Message-ID", "")),
|
|
447
|
+
provider="smtp",
|
|
448
|
+
accepted=accepted,
|
|
449
|
+
rejected=rejected,
|
|
450
|
+
raw_response=msg_resp,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
except TimeoutError as exc:
|
|
454
|
+
raise MailTimeoutError(f"SMTP transmission timed out: {exc}") from exc
|
|
455
|
+
except (OSError, ssl.SSLError) as exc:
|
|
456
|
+
raise MailConnectionError(f"SMTP connection or TLS error: {exc}") from exc
|
|
457
|
+
finally:
|
|
458
|
+
try:
|
|
459
|
+
current_writer.close()
|
|
460
|
+
await current_writer.wait_closed()
|
|
461
|
+
except (OSError, TimeoutError):
|
|
462
|
+
logger.debug("Error while closing SMTP client writer", exc_info=True)
|
|
463
|
+
del _kept_plain_writer
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
__all__ = ["SMTPTransport"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.queue.dispatcher import QueueDispatcher, push
|
|
4
|
+
from swiftpy.queue.job import Job, JobSerializationError
|
|
5
|
+
from swiftpy.queue.worker import QueueWorker
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Job",
|
|
9
|
+
"JobSerializationError",
|
|
10
|
+
"QueueDispatcher",
|
|
11
|
+
"QueueWorker",
|
|
12
|
+
"push",
|
|
13
|
+
]
|