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.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. 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
+ ]