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,172 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from swiftpy.mail.exceptions import MailConfigurationError, MailValidationError
9
+ from swiftpy.mail.message import MailMessage, parse_mail_address
10
+ from swiftpy.mail.models import MailSendResult
11
+ from swiftpy.mail.transports.api.base import ApiMailTransport
12
+
13
+
14
+ class SendGridTransport(ApiMailTransport):
15
+ """
16
+ Email delivery transport using SendGrid v3 Mail Send API (https://sendgrid.com).
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ api_key: str,
22
+ timeout: float = 30.0,
23
+ endpoint: str = "https://api.sendgrid.com/v3/mail/send",
24
+ client: httpx.AsyncClient | None = None,
25
+ default_from: tuple[str, str | None] | None = None,
26
+ max_attachment_size_bytes: int | None = None,
27
+ max_message_size_bytes: int | None = None,
28
+ ) -> None:
29
+ super().__init__(
30
+ timeout=timeout,
31
+ client=client,
32
+ default_from=default_from,
33
+ max_attachment_size_bytes=max_attachment_size_bytes,
34
+ max_message_size_bytes=max_message_size_bytes,
35
+ )
36
+ self.api_key = api_key
37
+ self.endpoint = endpoint
38
+
39
+ @classmethod
40
+ def from_config(
41
+ cls,
42
+ config: dict[str, Any],
43
+ default_from: tuple[str, str | None] | None = None,
44
+ max_attachment_size_bytes: int | None = None,
45
+ max_message_size_bytes: int | None = None,
46
+ ) -> SendGridTransport:
47
+ api_key = str(config.get("api_key", ""))
48
+ timeout = float(config.get("timeout", 30.0))
49
+ endpoint = str(config.get("endpoint", "https://api.sendgrid.com/v3/mail/send"))
50
+ return cls(
51
+ api_key=api_key,
52
+ timeout=timeout,
53
+ endpoint=endpoint,
54
+ default_from=default_from,
55
+ max_attachment_size_bytes=cls._resolve_size_limit(
56
+ max_attachment_size_bytes, config, "max_attachment_size_bytes"
57
+ ),
58
+ max_message_size_bytes=cls._resolve_size_limit(
59
+ max_message_size_bytes, config, "max_message_size_bytes"
60
+ ),
61
+ )
62
+
63
+ def _parse_recipient(self, formatted: str) -> dict[str, str]:
64
+ name, addr = parse_mail_address(formatted, field_name="recipient")
65
+ rec = {"email": addr}
66
+ if name:
67
+ rec["name"] = name
68
+ return rec
69
+
70
+ async def send(self, message: MailMessage) -> MailSendResult:
71
+ if not self.api_key:
72
+ raise MailConfigurationError("SendGrid API key is not configured")
73
+
74
+ if not message.to and not message.cc and not message.bcc:
75
+ raise MailValidationError(
76
+ "Email message must specify at least one recipient"
77
+ )
78
+
79
+ from_addr, from_name = self._resolve_from(
80
+ message.from_address, message.from_name
81
+ )
82
+ parsed_from_name, clean_from_addr = parse_mail_address(
83
+ from_addr, field_name="from"
84
+ )
85
+ from_payload: dict[str, str] = {"email": clean_from_addr}
86
+ resolved_from_name = (
87
+ from_name.strip()
88
+ if from_name is not None and from_name.strip()
89
+ else parsed_from_name
90
+ )
91
+ if resolved_from_name:
92
+ from_payload["name"] = resolved_from_name
93
+
94
+ personalization: dict[str, Any] = {
95
+ "to": [self._parse_recipient(r) for r in message.to]
96
+ }
97
+ if message.cc:
98
+ personalization["cc"] = [self._parse_recipient(r) for r in message.cc]
99
+ if message.bcc:
100
+ personalization["bcc"] = [self._parse_recipient(r) for r in message.bcc]
101
+
102
+ content_list: list[dict[str, str]] = []
103
+ if message.text_body:
104
+ content_list.append({"type": "text/plain", "value": message.text_body})
105
+ if message.html_body:
106
+ content_list.append({"type": "text/html", "value": message.html_body})
107
+ if not content_list:
108
+ content_list.append({"type": "text/plain", "value": ""})
109
+
110
+ payload: dict[str, Any] = {
111
+ "personalizations": [personalization],
112
+ "from": from_payload,
113
+ "subject": message.subject,
114
+ "content": content_list,
115
+ }
116
+
117
+ if message.reply_to:
118
+ payload["reply_to"] = self._parse_recipient(message.reply_to)
119
+
120
+ if message.headers:
121
+ payload["headers"] = {str(k): str(v) for k, v in message.headers.items()}
122
+ if message.tags:
123
+ payload["categories"] = [str(tag) for tag in message.tags]
124
+ if message.metadata:
125
+ payload["custom_args"] = {
126
+ str(k): str(v) for k, v in message.metadata.items()
127
+ }
128
+
129
+ loaded_attachments = await self._read_attachments_with_limits(message)
130
+ if loaded_attachments:
131
+ att_payload: list[dict[str, str]] = []
132
+ for att, data in loaded_attachments:
133
+ encoded = base64.b64encode(data).decode("ascii")
134
+ item = {
135
+ "content": encoded,
136
+ "filename": att.filename,
137
+ "type": att.content_type,
138
+ "disposition": att.disposition,
139
+ }
140
+ if att.cid:
141
+ item["content_id"] = att.cid
142
+ att_payload.append(item)
143
+ payload["attachments"] = att_payload
144
+
145
+ headers = {
146
+ "Authorization": f"Bearer {self.api_key}",
147
+ "Content-Type": "application/json",
148
+ "User-Agent": "SwiftPY-Mail/1.0",
149
+ }
150
+
151
+ client = await self._get_client()
152
+
153
+ try:
154
+ response = await client.post(self.endpoint, json=payload, headers=headers)
155
+ response.raise_for_status()
156
+ msg_id = response.headers.get("X-Message-Id") or response.headers.get(
157
+ "x-message-id"
158
+ )
159
+ return MailSendResult(
160
+ success=True,
161
+ message_id=msg_id,
162
+ provider="sendgrid",
163
+ accepted=message.all_recipient_addresses(),
164
+ rejected=[],
165
+ raw_response={"status_code": response.status_code},
166
+ )
167
+ except Exception as exc:
168
+ self._handle_http_error(exc, "SendGrid")
169
+ raise # pragma: no cover
170
+
171
+
172
+ __all__ = ["SendGridTransport"]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from swiftpy.mail.message import MailMessage
6
+ from swiftpy.mail.models import MailSendResult
7
+
8
+
9
+ class MailTransport(ABC):
10
+ """Abstract contract for SwiftPY email delivery backends."""
11
+
12
+ @abstractmethod
13
+ async def send(self, message: MailMessage) -> MailSendResult:
14
+ """
15
+ Deliver the given MailMessage.
16
+
17
+ Raises MailTransportError or specialized subclasses on network/auth failure.
18
+ """
19
+
20
+ async def close(self) -> None:
21
+ """Release underlying connections, pools, or open handles."""
22
+
23
+
24
+ __all__ = ["MailTransport"]
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from swiftpy.mail.exceptions import AttachmentTooLargeError
8
+ from swiftpy.mail.message import MailMessage
9
+ from swiftpy.mail.models import MailSendResult
10
+ from swiftpy.mail.transports.base import MailTransport
11
+
12
+
13
+ class ConsoleTransport(MailTransport):
14
+ """
15
+ Development and logging transport.
16
+ Outputs email metadata to the logger and retains sent messages in memory for inspection.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ channel: str = "mail",
22
+ *,
23
+ max_attachment_size_bytes: int | None = None,
24
+ max_message_size_bytes: int | None = None,
25
+ ) -> None:
26
+ self.channel = channel
27
+ self.logger = logging.getLogger(f"swiftpy.mail.{channel}")
28
+ self.sent_messages: list[MailMessage] = []
29
+ self.max_attachment_size_bytes = max_attachment_size_bytes
30
+ self.max_message_size_bytes = max_message_size_bytes
31
+
32
+ @classmethod
33
+ def from_config(
34
+ cls,
35
+ config: dict[str, Any],
36
+ max_attachment_size_bytes: int | None = None,
37
+ max_message_size_bytes: int | None = None,
38
+ ) -> ConsoleTransport:
39
+ channel = str(config.get("channel", "mail"))
40
+ raw_att = config.get("max_attachment_size_bytes", max_attachment_size_bytes)
41
+ raw_msg = config.get("max_message_size_bytes", max_message_size_bytes)
42
+ return cls(
43
+ channel=channel,
44
+ max_attachment_size_bytes=int(raw_att) if raw_att is not None else None,
45
+ max_message_size_bytes=int(raw_msg) if raw_msg is not None else None,
46
+ )
47
+
48
+ async def send(self, message: MailMessage) -> MailSendResult:
49
+ if (
50
+ self.max_attachment_size_bytes is not None
51
+ or self.max_message_size_bytes is not None
52
+ ):
53
+ total_bytes = len((message.text_body or "").encode("utf-8")) + len(
54
+ (message.html_body or "").encode("utf-8")
55
+ )
56
+ for att in message.attachments:
57
+ att_data = await att.read_bytes(
58
+ max_size=self.max_attachment_size_bytes
59
+ )
60
+ total_bytes += len(att_data)
61
+ if (
62
+ self.max_message_size_bytes is not None
63
+ and total_bytes > self.max_message_size_bytes
64
+ ):
65
+ raise AttachmentTooLargeError(
66
+ f"Total email size ({total_bytes} bytes) exceeds configured limit ({self.max_message_size_bytes} bytes)"
67
+ )
68
+
69
+ self.sent_messages.append(message)
70
+ msg_id = f"console-{uuid.uuid4().hex[:16]}"
71
+
72
+ self.logger.info(
73
+ "Email dispatched via ConsoleTransport: To=%s Subject='%s' Message-ID=%s",
74
+ message.to,
75
+ message.subject,
76
+ msg_id,
77
+ )
78
+
79
+ return MailSendResult(
80
+ success=True,
81
+ message_id=msg_id,
82
+ provider="console",
83
+ accepted=message.all_recipient_addresses(),
84
+ rejected=[],
85
+ )
86
+
87
+ async def close(self) -> None:
88
+ self.sent_messages.clear()
89
+
90
+
91
+ __all__ = ["ConsoleTransport"]
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from swiftpy.mail.envelope import Envelope
9
+ from swiftpy.mail.exceptions import (
10
+ MailConfigurationError,
11
+ MailTimeoutError,
12
+ MailTransportError,
13
+ )
14
+ from swiftpy.mail.message import MailMessage, parse_mail_address
15
+ from swiftpy.mail.models import MailSendResult
16
+ from swiftpy.mail.transports.base import MailTransport
17
+
18
+ logger = logging.getLogger("swiftpy.mail.sendmail")
19
+
20
+
21
+ class SendmailTransport(MailTransport):
22
+ """
23
+ Local sendmail binary transport.
24
+ Invokes sendmail non-blockingly via asyncio.create_subprocess_exec.
25
+ Enforces process timeouts and cancellation cleanup.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ path: str = "/usr/sbin/sendmail",
31
+ timeout: float = 30.0,
32
+ default_from: tuple[str, str | None] | None = None,
33
+ max_attachment_size_bytes: int | None = None,
34
+ max_message_size_bytes: int | None = None,
35
+ ) -> None:
36
+ self.path = path
37
+ self.timeout = timeout
38
+ self.default_from = default_from
39
+ self.max_attachment_size_bytes = max_attachment_size_bytes
40
+ self.max_message_size_bytes = max_message_size_bytes
41
+
42
+ @classmethod
43
+ def from_config(
44
+ cls,
45
+ config: dict[str, Any],
46
+ default_from: tuple[str, str | None] | None = None,
47
+ max_attachment_size_bytes: int | None = None,
48
+ max_message_size_bytes: int | None = None,
49
+ ) -> SendmailTransport:
50
+ path = str(config.get("path", "/usr/sbin/sendmail"))
51
+ timeout = float(config.get("timeout", 30.0))
52
+ raw_att = config.get("max_attachment_size_bytes", max_attachment_size_bytes)
53
+ raw_msg = config.get("max_message_size_bytes", max_message_size_bytes)
54
+ return cls(
55
+ path=path,
56
+ timeout=timeout,
57
+ default_from=default_from,
58
+ max_attachment_size_bytes=int(raw_att) if raw_att is not None else None,
59
+ max_message_size_bytes=int(raw_msg) if raw_msg is not None else None,
60
+ )
61
+
62
+ async def send(self, message: MailMessage) -> MailSendResult:
63
+ if not Path(self.path).exists():
64
+ raise MailConfigurationError(f"Sendmail binary not found at '{self.path}'")
65
+
66
+ email_msg = await Envelope.build(
67
+ message,
68
+ default_from=self.default_from,
69
+ max_message_size_bytes=self.max_message_size_bytes,
70
+ max_attachment_size_bytes=self.max_attachment_size_bytes,
71
+ )
72
+ raw_bytes = email_msg.as_bytes()
73
+
74
+ _, sender_addr = parse_mail_address(
75
+ str(email_msg["From"]), field_name="from_address"
76
+ )
77
+ all_recipients = [
78
+ parse_mail_address(r, field_name="recipient")[1]
79
+ for r in message.to + message.cc + message.bcc
80
+ ]
81
+ cmd = [self.path, "-i", "-f", sender_addr, "--", *all_recipients]
82
+
83
+ proc = await asyncio.create_subprocess_exec(
84
+ *cmd,
85
+ stdin=asyncio.subprocess.PIPE,
86
+ stdout=asyncio.subprocess.PIPE,
87
+ stderr=asyncio.subprocess.PIPE,
88
+ )
89
+
90
+ try:
91
+ stdout, stderr = await asyncio.wait_for(
92
+ proc.communicate(input=raw_bytes),
93
+ timeout=self.timeout,
94
+ )
95
+ except TimeoutError as exc:
96
+ try:
97
+ proc.kill()
98
+ await proc.wait()
99
+ except Exception:
100
+ logger.debug("Failed to kill timed-out sendmail process", exc_info=True)
101
+ raise MailTimeoutError(
102
+ f"Sendmail process timed out after {self.timeout}s"
103
+ ) from exc
104
+ except asyncio.CancelledError:
105
+ try:
106
+ proc.kill()
107
+ await proc.wait()
108
+ except Exception:
109
+ logger.debug("Failed to kill cancelled sendmail process", exc_info=True)
110
+ raise
111
+
112
+ if proc.returncode != 0:
113
+ err_text = stderr.decode("utf-8", errors="replace").strip()
114
+ raise MailTransportError(
115
+ f"Sendmail process exited with code {proc.returncode}: {err_text}"
116
+ )
117
+
118
+ return MailSendResult(
119
+ success=True,
120
+ message_id=str(email_msg.get("Message-ID", "")),
121
+ provider="sendmail",
122
+ accepted=all_recipients,
123
+ rejected=[],
124
+ raw_response=stdout.decode("utf-8", errors="replace"),
125
+ )
126
+
127
+
128
+ __all__ = ["SendmailTransport"]