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,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"]
|