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,21 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.mail.transports.api.base import ApiMailTransport
4
+ from swiftpy.mail.transports.api.mailgun import MailgunTransport
5
+ from swiftpy.mail.transports.api.resend import ResendTransport
6
+ from swiftpy.mail.transports.api.sendgrid import SendGridTransport
7
+ from swiftpy.mail.transports.base import MailTransport
8
+ from swiftpy.mail.transports.console import ConsoleTransport
9
+ from swiftpy.mail.transports.sendmail import SendmailTransport
10
+ from swiftpy.mail.transports.smtp import SMTPTransport
11
+
12
+ __all__ = [
13
+ "ApiMailTransport",
14
+ "ConsoleTransport",
15
+ "MailTransport",
16
+ "MailgunTransport",
17
+ "ResendTransport",
18
+ "SMTPTransport",
19
+ "SendGridTransport",
20
+ "SendmailTransport",
21
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.mail.transports.api.base import ApiMailTransport
4
+ from swiftpy.mail.transports.api.mailgun import MailgunTransport
5
+ from swiftpy.mail.transports.api.resend import ResendTransport
6
+ from swiftpy.mail.transports.api.sendgrid import SendGridTransport
7
+
8
+ __all__ = [
9
+ "ApiMailTransport",
10
+ "MailgunTransport",
11
+ "ResendTransport",
12
+ "SendGridTransport",
13
+ ]
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import email.utils
4
+ import html
5
+ import re
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from swiftpy.mail.attachments import Attachment
12
+ from swiftpy.mail.exceptions import (
13
+ AttachmentTooLargeError,
14
+ MailAuthenticationError,
15
+ MailConnectionError,
16
+ MailRateLimitError,
17
+ MailTimeoutError,
18
+ MailTransportError,
19
+ MailValidationError,
20
+ )
21
+ from swiftpy.mail.message import MailMessage
22
+ from swiftpy.mail.transports.base import MailTransport
23
+
24
+ _HTML_TAG_RE = re.compile(r"<[^>]+>")
25
+ _SECRET_TOKEN_RE = re.compile(
26
+ r"(?i)(?:"
27
+ r"bearer\s+[A-Za-z0-9._\-]+"
28
+ r"|\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9_\-]{6,}"
29
+ r"|\bkey-[A-Za-z0-9_\-]{6,}"
30
+ r"|\bSG\.[A-Za-z0-9_.\-]{6,}"
31
+ r"|(?:api[_-]?key|token|secret|password)\s*[:=]\s*['\"]?[^\s'\";,]+"
32
+ r")"
33
+ )
34
+
35
+
36
+ def _sanitize_error_body(raw_text: str, *, max_len: int = 300) -> str:
37
+ """Strip HTML tags, redact credentials/tokens, and truncate upstream error bodies."""
38
+ plain = html.unescape(_HTML_TAG_RE.sub(" ", raw_text))
39
+ redacted = _SECRET_TOKEN_RE.sub("[REDACTED]", plain)
40
+ collapsed = re.sub(r"\s+", " ", redacted).strip()
41
+ if len(collapsed) > max_len:
42
+ return f"{collapsed[: max_len - 3]}..."
43
+ return collapsed
44
+
45
+
46
+ def _parse_retry_after(raw_header: str | None) -> float | None:
47
+ """Parse numeric or HTTP-date Retry-After header into seconds."""
48
+ if not raw_header:
49
+ return None
50
+ stripped = raw_header.strip()
51
+ if not stripped:
52
+ return None
53
+ try:
54
+ return max(0.0, float(stripped))
55
+ except ValueError:
56
+ try:
57
+ parsed_dt = email.utils.parsedate_to_datetime(stripped)
58
+ except (TypeError, ValueError):
59
+ return None
60
+ if parsed_dt.tzinfo is None:
61
+ parsed_dt = parsed_dt.replace(tzinfo=UTC)
62
+ return max(0.0, (parsed_dt - datetime.now(UTC)).total_seconds())
63
+
64
+
65
+ class ApiMailTransport(MailTransport):
66
+ """Base transport for HTTP API email service providers."""
67
+
68
+ def __init__(
69
+ self,
70
+ timeout: float = 30.0,
71
+ client: httpx.AsyncClient | None = None,
72
+ default_from: tuple[str, str | None] | None = None,
73
+ max_attachment_size_bytes: int | None = None,
74
+ max_message_size_bytes: int | None = None,
75
+ ) -> None:
76
+ self.timeout = timeout
77
+ self._client = client
78
+ self._owns_client: bool = client is None
79
+ self._closed: bool = False
80
+ self.default_from = default_from
81
+ self.max_attachment_size_bytes = max_attachment_size_bytes
82
+ self.max_message_size_bytes = max_message_size_bytes
83
+
84
+ @staticmethod
85
+ def _resolve_size_limit(
86
+ explicit: int | None, config: dict[str, Any], key: str
87
+ ) -> int | None:
88
+ if explicit is not None:
89
+ return explicit
90
+ raw = config.get(key)
91
+ return int(raw) if raw is not None else None
92
+
93
+ async def _read_attachments_with_limits(
94
+ self, message: MailMessage
95
+ ) -> list[tuple[Attachment, bytes]]:
96
+ """Read attachments and enforce configured per-attachment and total size limits."""
97
+ total_bytes = len((message.text_body or "").encode("utf-8")) + len(
98
+ (message.html_body or "").encode("utf-8")
99
+ )
100
+ loaded: list[tuple[Attachment, bytes]] = []
101
+ for att in message.attachments:
102
+ content = await att.read_bytes(max_size=self.max_attachment_size_bytes)
103
+ total_bytes += len(content)
104
+ if (
105
+ self.max_message_size_bytes is not None
106
+ and total_bytes > self.max_message_size_bytes
107
+ ):
108
+ raise AttachmentTooLargeError(
109
+ f"Total email size ({total_bytes} bytes) exceeds limit ({self.max_message_size_bytes} bytes)"
110
+ )
111
+ loaded.append((att, content))
112
+
113
+ if (
114
+ self.max_message_size_bytes is not None
115
+ and total_bytes > self.max_message_size_bytes
116
+ ):
117
+ raise AttachmentTooLargeError(
118
+ f"Total email size ({total_bytes} bytes) exceeds limit ({self.max_message_size_bytes} bytes)"
119
+ )
120
+ return loaded
121
+
122
+ async def _get_client(self) -> httpx.AsyncClient:
123
+ if self._closed:
124
+ raise MailConnectionError("Transport has been closed")
125
+ if self._client is None or (self._owns_client and self._client.is_closed):
126
+ self._client = httpx.AsyncClient(timeout=self.timeout)
127
+ self._owns_client = True
128
+ elif not self._owns_client and self._client.is_closed:
129
+ raise MailConnectionError(
130
+ "External HTTP client provided to transport has been closed"
131
+ )
132
+ return self._client
133
+
134
+ def _resolve_from(
135
+ self,
136
+ from_address: str | None,
137
+ from_name: str | None,
138
+ ) -> tuple[str, str | None]:
139
+ if from_address:
140
+ return from_address, from_name
141
+ if self.default_from:
142
+ return self.default_from[0], from_name or self.default_from[1]
143
+ raise MailValidationError(
144
+ "Email message must specify a from_address or have a default configured"
145
+ )
146
+
147
+ def _handle_http_error(self, exc: Exception, provider_name: str) -> None:
148
+ if isinstance(exc, httpx.HTTPStatusError):
149
+ code = exc.response.status_code
150
+ text = _sanitize_error_body(exc.response.text)
151
+ if code in (401, 403):
152
+ raise MailAuthenticationError(
153
+ f"{provider_name} API authentication failed ({code}): {text}"
154
+ ) from exc
155
+ if code in (400, 422):
156
+ raise MailValidationError(
157
+ f"{provider_name} API validation error ({code}): {text}"
158
+ ) from exc
159
+ if code == 429:
160
+ retry_after_val = _parse_retry_after(
161
+ exc.response.headers.get("Retry-After")
162
+ )
163
+ raise MailRateLimitError(
164
+ f"{provider_name} API rate limit exceeded ({code}): {text}",
165
+ retry_after=retry_after_val,
166
+ ) from exc
167
+ raise MailTransportError(
168
+ f"{provider_name} API HTTP error ({code}): {text}"
169
+ ) from exc
170
+ if isinstance(exc, httpx.TimeoutException):
171
+ raise MailTimeoutError(
172
+ f"{provider_name} API request timed out: {exc}"
173
+ ) from exc
174
+ if isinstance(exc, httpx.RequestError):
175
+ raise MailConnectionError(
176
+ f"{provider_name} API network connection error: {exc}"
177
+ ) from exc
178
+ raise MailTransportError(
179
+ f"Unexpected error communicating with {provider_name}: {exc}"
180
+ ) from exc
181
+
182
+ async def close(self) -> None:
183
+ """Close the underlying HTTP client if this transport created and owns it."""
184
+ self._closed = True
185
+ if (
186
+ self._owns_client
187
+ and self._client is not None
188
+ and not self._client.is_closed
189
+ ):
190
+ await self._client.aclose()
191
+ self._client = None
192
+
193
+
194
+ __all__ = ["ApiMailTransport"]
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from swiftpy.mail.exceptions import MailConfigurationError, MailValidationError
8
+ from swiftpy.mail.message import MailMessage, format_mail_address
9
+ from swiftpy.mail.models import MailSendResult
10
+ from swiftpy.mail.transports.api.base import ApiMailTransport
11
+
12
+ type _MultipartFileValue = tuple[str | None, str] | tuple[str | None, bytes, str]
13
+
14
+
15
+ class MailgunTransport(ApiMailTransport):
16
+ """
17
+ Email delivery transport using Mailgun REST API (https://mailgun.com).
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ api_key: str,
23
+ domain: str,
24
+ timeout: float = 30.0,
25
+ endpoint: str = "https://api.mailgun.net/v3",
26
+ client: httpx.AsyncClient | None = None,
27
+ default_from: tuple[str, str | None] | None = None,
28
+ max_attachment_size_bytes: int | None = None,
29
+ max_message_size_bytes: int | None = None,
30
+ ) -> None:
31
+ super().__init__(
32
+ timeout=timeout,
33
+ client=client,
34
+ default_from=default_from,
35
+ max_attachment_size_bytes=max_attachment_size_bytes,
36
+ max_message_size_bytes=max_message_size_bytes,
37
+ )
38
+ self.api_key = api_key
39
+ self.domain = domain
40
+ self.endpoint = endpoint.rstrip("/")
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
+ ) -> MailgunTransport:
50
+ api_key = str(config.get("api_key", ""))
51
+ domain = str(config.get("domain", ""))
52
+ timeout = float(config.get("timeout", 30.0))
53
+ endpoint = str(config.get("endpoint", "https://api.mailgun.net/v3"))
54
+ return cls(
55
+ api_key=api_key,
56
+ domain=domain,
57
+ timeout=timeout,
58
+ endpoint=endpoint,
59
+ default_from=default_from,
60
+ max_attachment_size_bytes=cls._resolve_size_limit(
61
+ max_attachment_size_bytes, config, "max_attachment_size_bytes"
62
+ ),
63
+ max_message_size_bytes=cls._resolve_size_limit(
64
+ max_message_size_bytes, config, "max_message_size_bytes"
65
+ ),
66
+ )
67
+
68
+ async def send(self, message: MailMessage) -> MailSendResult:
69
+ if not self.api_key:
70
+ raise MailConfigurationError("Mailgun API key is not configured")
71
+ if not self.domain:
72
+ raise MailConfigurationError("Mailgun domain 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
+ formatted_from = format_mail_address(from_addr, from_name, field_name="from")
83
+
84
+ multipart_fields: list[tuple[str, _MultipartFileValue]] = [
85
+ ("from", (None, formatted_from)),
86
+ ("subject", (None, message.subject)),
87
+ ]
88
+ for recipient in message.to:
89
+ multipart_fields.append(
90
+ ("to", (None, format_mail_address(recipient, field_name="to")))
91
+ )
92
+ for recipient in message.cc:
93
+ multipart_fields.append(
94
+ ("cc", (None, format_mail_address(recipient, field_name="cc")))
95
+ )
96
+ for recipient in message.bcc:
97
+ multipart_fields.append(
98
+ ("bcc", (None, format_mail_address(recipient, field_name="bcc")))
99
+ )
100
+
101
+ if message.reply_to:
102
+ multipart_fields.append(
103
+ (
104
+ "h:Reply-To",
105
+ (None, format_mail_address(message.reply_to, field_name="reply_to")),
106
+ )
107
+ )
108
+
109
+ if message.text_body:
110
+ multipart_fields.append(("text", (None, message.text_body)))
111
+ if message.html_body:
112
+ multipart_fields.append(("html", (None, message.html_body)))
113
+ if not message.text_body and not message.html_body:
114
+ multipart_fields.append(("text", (None, "")))
115
+
116
+ for k, v in message.headers.items():
117
+ multipart_fields.append((f"h:{k}", (None, str(v))))
118
+ for tag in message.tags:
119
+ multipart_fields.append(("o:tag", (None, str(tag))))
120
+ for k, v in message.metadata.items():
121
+ multipart_fields.append((f"v:{k}", (None, str(v))))
122
+
123
+ loaded_attachments = await self._read_attachments_with_limits(message)
124
+ for att, content in loaded_attachments:
125
+ field_name = "inline" if att.disposition == "inline" else "attachment"
126
+ multipart_fields.append(
127
+ (field_name, (att.filename, content, att.content_type))
128
+ )
129
+
130
+ url = f"{self.endpoint}/{self.domain}/messages"
131
+ client = await self._get_client()
132
+
133
+ try:
134
+ response = await client.post(
135
+ url,
136
+ files=multipart_fields,
137
+ auth=("api", self.api_key),
138
+ )
139
+ response.raise_for_status()
140
+ try:
141
+ res_data = response.json()
142
+ except (ValueError, KeyError, httpx.DecodingError):
143
+ res_data = {"raw": response.text}
144
+ msg_id = str(res_data.get("id", "")) if isinstance(res_data, dict) else ""
145
+ return MailSendResult(
146
+ success=True,
147
+ message_id=msg_id,
148
+ provider="mailgun",
149
+ accepted=message.all_recipient_addresses(),
150
+ rejected=[],
151
+ raw_response=res_data,
152
+ )
153
+ except Exception as exc:
154
+ self._handle_http_error(exc, "Mailgun")
155
+ raise # pragma: no cover
156
+
157
+
158
+ __all__ = ["MailgunTransport"]
@@ -0,0 +1,156 @@
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, format_mail_address
10
+ from swiftpy.mail.models import MailSendResult
11
+ from swiftpy.mail.transports.api.base import ApiMailTransport
12
+
13
+
14
+ class ResendTransport(ApiMailTransport):
15
+ """
16
+ Email delivery transport using the Resend REST API (https://resend.com).
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ api_key: str,
22
+ timeout: float = 30.0,
23
+ endpoint: str = "https://api.resend.com/emails",
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
+ ) -> ResendTransport:
47
+ api_key = str(config.get("api_key", ""))
48
+ timeout = float(config.get("timeout", 30.0))
49
+ endpoint = str(config.get("endpoint", "https://api.resend.com/emails"))
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
+ async def send(self, message: MailMessage) -> MailSendResult:
64
+ if not self.api_key:
65
+ raise MailConfigurationError("Resend API key is not configured")
66
+
67
+ if not message.to and not message.cc and not message.bcc:
68
+ raise MailValidationError(
69
+ "Email message must specify at least one recipient"
70
+ )
71
+
72
+ from_addr, from_name = self._resolve_from(
73
+ message.from_address, message.from_name
74
+ )
75
+ formatted_from = format_mail_address(from_addr, from_name, field_name="from")
76
+
77
+ payload: dict[str, Any] = {
78
+ "from": formatted_from,
79
+ "to": [format_mail_address(r, field_name="to") for r in message.to],
80
+ "subject": message.subject,
81
+ }
82
+
83
+ if message.cc:
84
+ payload["cc"] = [
85
+ format_mail_address(r, field_name="cc") for r in message.cc
86
+ ]
87
+ if message.bcc:
88
+ payload["bcc"] = [
89
+ format_mail_address(r, field_name="bcc") for r in message.bcc
90
+ ]
91
+ if message.reply_to:
92
+ payload["reply_to"] = format_mail_address(
93
+ message.reply_to, field_name="reply_to"
94
+ )
95
+
96
+ if message.html_body:
97
+ payload["html"] = message.html_body
98
+ if message.text_body:
99
+ payload["text"] = message.text_body
100
+ if not message.html_body and not message.text_body:
101
+ payload["text"] = ""
102
+
103
+ if message.headers:
104
+ payload["headers"] = {str(k): str(v) for k, v in message.headers.items()}
105
+
106
+ loaded_attachments = await self._read_attachments_with_limits(message)
107
+ if loaded_attachments:
108
+ att_payload: list[dict[str, str]] = []
109
+ for att, data in loaded_attachments:
110
+ encoded = base64.b64encode(data).decode("ascii")
111
+ att_payload.append(
112
+ {
113
+ "filename": att.filename,
114
+ "content": encoded,
115
+ }
116
+ )
117
+ payload["attachments"] = att_payload
118
+
119
+ resend_tags: list[dict[str, str]] = [
120
+ {"name": tag, "value": "true"} for tag in message.tags
121
+ ]
122
+ for meta_key, meta_val in message.metadata.items():
123
+ resend_tags.append({"name": str(meta_key), "value": str(meta_val)})
124
+ if resend_tags:
125
+ payload["tags"] = resend_tags
126
+
127
+ headers = {
128
+ "Authorization": f"Bearer {self.api_key}",
129
+ "Content-Type": "application/json",
130
+ "User-Agent": "SwiftPY-Mail/1.0",
131
+ }
132
+
133
+ client = await self._get_client()
134
+
135
+ try:
136
+ response = await client.post(self.endpoint, json=payload, headers=headers)
137
+ response.raise_for_status()
138
+ try:
139
+ res_json = response.json()
140
+ except (ValueError, KeyError, httpx.DecodingError):
141
+ res_json = {"raw": response.text}
142
+ msg_id = str(res_json.get("id", "")) if isinstance(res_json, dict) else ""
143
+ return MailSendResult(
144
+ success=True,
145
+ message_id=msg_id,
146
+ provider="resend",
147
+ accepted=message.all_recipient_addresses(),
148
+ rejected=[],
149
+ raw_response=res_json,
150
+ )
151
+ except Exception as exc:
152
+ self._handle_http_error(exc, "Resend")
153
+ raise # pragma: no cover
154
+
155
+
156
+ __all__ = ["ResendTransport"]