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