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
swiftpy/mail/message.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import email.utils
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
from swiftpy.mail.attachments import Attachment
|
|
10
|
+
from swiftpy.mail.exceptions import (
|
|
11
|
+
HeaderInjectionError,
|
|
12
|
+
InvalidAddressError,
|
|
13
|
+
MailValidationError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_CRLF_PATTERN = re.compile(r"[\r\n\0]")
|
|
17
|
+
_BASIC_EMAIL_REGEX = re.compile(
|
|
18
|
+
r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _validate_no_crlf(value: str, field_name: str) -> None:
|
|
23
|
+
if _CRLF_PATTERN.search(value):
|
|
24
|
+
raise HeaderInjectionError(
|
|
25
|
+
f"Detected CRLF or null byte injection in {field_name}: {value!r}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_mail_address(
|
|
30
|
+
raw_address: str, field_name: str = "recipient"
|
|
31
|
+
) -> tuple[str, str]:
|
|
32
|
+
"""Parse and validate an email address or 'Display Name <addr>' into (name, email)."""
|
|
33
|
+
_validate_no_crlf(raw_address, field_name)
|
|
34
|
+
cleaned = raw_address.strip()
|
|
35
|
+
if not cleaned:
|
|
36
|
+
raise InvalidAddressError(f"{field_name} address cannot be empty")
|
|
37
|
+
|
|
38
|
+
name, addr = email.utils.parseaddr(cleaned)
|
|
39
|
+
if not addr and cleaned.endswith(">") and "<" in cleaned:
|
|
40
|
+
prefix, _, candidate = cleaned[:-1].rpartition("<")
|
|
41
|
+
candidate_stripped = candidate.strip()
|
|
42
|
+
if (
|
|
43
|
+
candidate_stripped
|
|
44
|
+
and "<" not in candidate_stripped
|
|
45
|
+
and ">" not in candidate_stripped
|
|
46
|
+
and "," not in candidate_stripped
|
|
47
|
+
):
|
|
48
|
+
addr = candidate_stripped
|
|
49
|
+
name = prefix.strip().strip('"')
|
|
50
|
+
|
|
51
|
+
target = addr if addr else cleaned
|
|
52
|
+
if not _BASIC_EMAIL_REGEX.match(target):
|
|
53
|
+
raise InvalidAddressError(f"Invalid email address '{cleaned}' in {field_name}")
|
|
54
|
+
return name.strip(), target
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_mail_address(
|
|
58
|
+
raw_or_address: str,
|
|
59
|
+
name: str | None = None,
|
|
60
|
+
*,
|
|
61
|
+
field_name: str = "recipient",
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Validate and RFC-format an address, quoting display names containing commas."""
|
|
64
|
+
parsed_name, addr = parse_mail_address(raw_or_address, field_name=field_name)
|
|
65
|
+
display_name = name.strip() if name is not None and name.strip() else parsed_name
|
|
66
|
+
if display_name:
|
|
67
|
+
_validate_no_crlf(display_name, f"{field_name}_name")
|
|
68
|
+
return email.utils.formataddr((display_name, addr))
|
|
69
|
+
return addr
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_address(raw_address: str, field_name: str = "recipient") -> str:
|
|
73
|
+
"""Validate and normalize an email address or formatted name <addr> string."""
|
|
74
|
+
parse_mail_address(raw_address, field_name=field_name)
|
|
75
|
+
return raw_address.strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize_recipients(
|
|
79
|
+
recipients: list[str] | tuple[str, ...] | str | None,
|
|
80
|
+
field_name: str,
|
|
81
|
+
) -> list[str]:
|
|
82
|
+
if recipients is None:
|
|
83
|
+
return []
|
|
84
|
+
if isinstance(recipients, str):
|
|
85
|
+
recipients = [recipients]
|
|
86
|
+
normalized: list[str] = []
|
|
87
|
+
for r in recipients:
|
|
88
|
+
validated = _validate_address(str(r), field_name)
|
|
89
|
+
normalized.append(validated)
|
|
90
|
+
return normalized
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(slots=True)
|
|
94
|
+
class MailMessage:
|
|
95
|
+
"""Represents a validated, delivery-ready email message."""
|
|
96
|
+
|
|
97
|
+
subject: str
|
|
98
|
+
to: list[str] = field(default_factory=list)
|
|
99
|
+
cc: list[str] = field(default_factory=list)
|
|
100
|
+
bcc: list[str] = field(default_factory=list)
|
|
101
|
+
from_address: str | None = None
|
|
102
|
+
from_name: str | None = None
|
|
103
|
+
reply_to: str | None = None
|
|
104
|
+
text_body: str | None = None
|
|
105
|
+
html_body: str | None = None
|
|
106
|
+
template: str | None = None
|
|
107
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
attachments: list[Attachment] = field(default_factory=list)
|
|
109
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
110
|
+
tags: list[str] = field(default_factory=list)
|
|
111
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
112
|
+
|
|
113
|
+
def __post_init__(self) -> None:
|
|
114
|
+
self._validate()
|
|
115
|
+
|
|
116
|
+
def _validate(self) -> None:
|
|
117
|
+
# Validate subject
|
|
118
|
+
if self.subject is None:
|
|
119
|
+
raise MailValidationError("Email subject cannot be None")
|
|
120
|
+
_validate_no_crlf(self.subject, "subject")
|
|
121
|
+
|
|
122
|
+
# Normalize & validate recipient lists
|
|
123
|
+
self.to = _normalize_recipients(self.to, "to")
|
|
124
|
+
self.cc = _normalize_recipients(self.cc, "cc")
|
|
125
|
+
self.bcc = _normalize_recipients(self.bcc, "bcc")
|
|
126
|
+
|
|
127
|
+
# Validate from_address and from_name
|
|
128
|
+
if self.from_address:
|
|
129
|
+
self.from_address = _validate_address(self.from_address, "from_address")
|
|
130
|
+
if self.from_name:
|
|
131
|
+
_validate_no_crlf(self.from_name, "from_name")
|
|
132
|
+
|
|
133
|
+
# Validate reply_to
|
|
134
|
+
if self.reply_to:
|
|
135
|
+
self.reply_to = _validate_address(self.reply_to, "reply_to")
|
|
136
|
+
|
|
137
|
+
# Validate headers
|
|
138
|
+
normalized_headers: dict[str, str] = {}
|
|
139
|
+
for k, v in self.headers.items():
|
|
140
|
+
_validate_no_crlf(k, f"header key '{k}'")
|
|
141
|
+
_validate_no_crlf(v, f"header value for '{k}'")
|
|
142
|
+
normalized_headers[k.strip()] = v.strip()
|
|
143
|
+
self.headers = normalized_headers
|
|
144
|
+
|
|
145
|
+
def attach(self, attachment: Attachment) -> Self:
|
|
146
|
+
"""Add an attachment to the message."""
|
|
147
|
+
self.attachments.append(attachment)
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def attach_from_path(
|
|
151
|
+
self,
|
|
152
|
+
path: str | Path,
|
|
153
|
+
filename: str | None = None,
|
|
154
|
+
content_type: str | None = None,
|
|
155
|
+
) -> Self:
|
|
156
|
+
"""Add a file attachment from a disk path."""
|
|
157
|
+
self.attachments.append(
|
|
158
|
+
Attachment.from_path(path, filename=filename, content_type=content_type)
|
|
159
|
+
)
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def attach_from_bytes(
|
|
163
|
+
self,
|
|
164
|
+
content: bytes,
|
|
165
|
+
filename: str,
|
|
166
|
+
content_type: str | None = None,
|
|
167
|
+
) -> Self:
|
|
168
|
+
"""Add an in-memory byte attachment."""
|
|
169
|
+
self.attachments.append(
|
|
170
|
+
Attachment.from_bytes(content, filename=filename, content_type=content_type)
|
|
171
|
+
)
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def add_header(self, key: str, value: str) -> Self:
|
|
175
|
+
"""Add a custom email header with CRLF injection validation."""
|
|
176
|
+
_validate_no_crlf(key, f"header key '{key}'")
|
|
177
|
+
_validate_no_crlf(value, f"header value for '{key}'")
|
|
178
|
+
self.headers[key.strip()] = value.strip()
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def all_recipient_addresses(self) -> list[str]:
|
|
182
|
+
"""Extract just the pure email addresses from to, cc, and bcc."""
|
|
183
|
+
return [parse_mail_address(r, field_name="recipient")[1] for r in self.to + self.cc + self.bcc]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
__all__ = ["MailMessage", "format_mail_address", "parse_mail_address"]
|
swiftpy/mail/models.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class MailSendResult:
|
|
9
|
+
"""Normalized delivery outcome returned by all mail transports."""
|
|
10
|
+
|
|
11
|
+
success: bool
|
|
12
|
+
message_id: str | None = None
|
|
13
|
+
provider: str | None = None
|
|
14
|
+
accepted: list[str] = field(default_factory=list)
|
|
15
|
+
rejected: list[str] = field(default_factory=list)
|
|
16
|
+
raw_response: Any = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["MailSendResult"]
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import importlib
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import textwrap
|
|
8
|
+
import types
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Any, ClassVar
|
|
11
|
+
|
|
12
|
+
from swiftpy.mail.exceptions import (
|
|
13
|
+
MailAuthenticationError,
|
|
14
|
+
MailConfigurationError,
|
|
15
|
+
MailConnectionError,
|
|
16
|
+
MailRateLimitError,
|
|
17
|
+
MailTimeoutError,
|
|
18
|
+
MailTransportError,
|
|
19
|
+
MailValidationError,
|
|
20
|
+
)
|
|
21
|
+
from swiftpy.mail.mailable import Mailable
|
|
22
|
+
from swiftpy.mail.manager import MailManager
|
|
23
|
+
from swiftpy.queue.job import Job, is_trusted_class_path, parse_class_path
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("swiftpy.mail.queue")
|
|
26
|
+
|
|
27
|
+
_MISSING = object()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _collect_self_attr_targets(node: ast.AST, targets: set[str]) -> None:
|
|
31
|
+
"""Recursively collect ``self.<attr>`` names from AST assignment targets."""
|
|
32
|
+
if isinstance(node, ast.Attribute):
|
|
33
|
+
if (
|
|
34
|
+
isinstance(node.value, ast.Name)
|
|
35
|
+
and node.value.id == "self"
|
|
36
|
+
and not node.attr.startswith("_")
|
|
37
|
+
):
|
|
38
|
+
targets.add(node.attr)
|
|
39
|
+
elif isinstance(node, (ast.Tuple, ast.List)):
|
|
40
|
+
for elt in node.elts:
|
|
41
|
+
_collect_self_attr_targets(elt, targets)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _allowed_state_fields(target_cls: type[Mailable]) -> set[str]:
|
|
45
|
+
"""Return declared public instance/class attributes permitted in ``mailable_state``."""
|
|
46
|
+
allowed: set[str] = set()
|
|
47
|
+
for cls_in_mro in target_cls.__mro__:
|
|
48
|
+
if cls_in_mro is object:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
for name in inspect.get_annotations(cls_in_mro):
|
|
52
|
+
if isinstance(name, str) and not name.startswith("_"):
|
|
53
|
+
allowed.add(name)
|
|
54
|
+
|
|
55
|
+
for attr_name, attr_val in cls_in_mro.__dict__.items():
|
|
56
|
+
if attr_name.startswith("_"):
|
|
57
|
+
continue
|
|
58
|
+
if not callable(attr_val) and (
|
|
59
|
+
isinstance(attr_val, types.MemberDescriptorType)
|
|
60
|
+
or (
|
|
61
|
+
not hasattr(attr_val, "__get__")
|
|
62
|
+
and not hasattr(attr_val, "__set__")
|
|
63
|
+
)
|
|
64
|
+
):
|
|
65
|
+
allowed.add(attr_name)
|
|
66
|
+
|
|
67
|
+
init_fn = cls_in_mro.__dict__.get("__init__")
|
|
68
|
+
if callable(init_fn):
|
|
69
|
+
try:
|
|
70
|
+
sig = inspect.signature(init_fn)
|
|
71
|
+
except (ValueError, TypeError):
|
|
72
|
+
sig = None
|
|
73
|
+
if sig is not None:
|
|
74
|
+
for param_name, param in sig.parameters.items():
|
|
75
|
+
if (
|
|
76
|
+
param_name != "self"
|
|
77
|
+
and not param_name.startswith("_")
|
|
78
|
+
and param.kind
|
|
79
|
+
not in (
|
|
80
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
81
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
82
|
+
)
|
|
83
|
+
):
|
|
84
|
+
allowed.add(param_name)
|
|
85
|
+
|
|
86
|
+
for member in cls_in_mro.__dict__.values():
|
|
87
|
+
if not inspect.isfunction(member):
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
source = textwrap.dedent(inspect.getsource(member))
|
|
91
|
+
tree = ast.parse(source)
|
|
92
|
+
except (OSError, TypeError, SyntaxError):
|
|
93
|
+
continue
|
|
94
|
+
for ast_node in ast.walk(tree):
|
|
95
|
+
if isinstance(ast_node, ast.Assign):
|
|
96
|
+
for target in ast_node.targets:
|
|
97
|
+
_collect_self_attr_targets(target, allowed)
|
|
98
|
+
elif isinstance(ast_node, (ast.AnnAssign, ast.AugAssign)):
|
|
99
|
+
_collect_self_attr_targets(ast_node.target, allowed)
|
|
100
|
+
|
|
101
|
+
return allowed
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SendQueuedMailableJob(Job):
|
|
105
|
+
"""
|
|
106
|
+
Asynchronous background job for delivering a Mailable.
|
|
107
|
+
Handles serialization, execution, and retry semantics.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
queue = "emails"
|
|
111
|
+
tries = 3
|
|
112
|
+
backoff: int | float | Sequence[int | float] = (5, 15, 60)
|
|
113
|
+
_trusted_mailable_classes: ClassVar[set[str]] = set()
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def allow_mailable_class(cls, mailable_cls_or_path: type[Mailable] | str) -> str:
|
|
117
|
+
"""Explicitly allow deserialization of a Mailable class or module path."""
|
|
118
|
+
if isinstance(mailable_cls_or_path, str):
|
|
119
|
+
mod_name, cls_name = parse_class_path(
|
|
120
|
+
mailable_cls_or_path, exc_cls=MailValidationError
|
|
121
|
+
)
|
|
122
|
+
class_path = f"{mod_name}:{cls_name}"
|
|
123
|
+
elif isinstance(mailable_cls_or_path, type) and issubclass(
|
|
124
|
+
mailable_cls_or_path, Mailable
|
|
125
|
+
):
|
|
126
|
+
class_path = (
|
|
127
|
+
f"{mailable_cls_or_path.__module__}:{mailable_cls_or_path.__qualname__}"
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
raise MailValidationError(
|
|
131
|
+
"allow_mailable_class requires a Mailable subclass or valid class path"
|
|
132
|
+
)
|
|
133
|
+
cls._trusted_mailable_classes.add(class_path)
|
|
134
|
+
return class_path
|
|
135
|
+
|
|
136
|
+
def __init__(self, mailable: Mailable | None = None, **kwargs: Any) -> None:
|
|
137
|
+
super().__init__(**kwargs)
|
|
138
|
+
self.mailable = mailable
|
|
139
|
+
|
|
140
|
+
if mailable is not None:
|
|
141
|
+
self.queue = getattr(mailable, "queue", self.queue)
|
|
142
|
+
self.tries = getattr(mailable, "tries", self.tries)
|
|
143
|
+
self.backoff = getattr(mailable, "backoff", self.backoff)
|
|
144
|
+
self.timeout = getattr(mailable, "timeout", self.timeout)
|
|
145
|
+
|
|
146
|
+
# Store mailable class and state in data
|
|
147
|
+
self.data["mailable_class"] = (
|
|
148
|
+
f"{mailable.__class__.__module__}:{mailable.__class__.__qualname__}"
|
|
149
|
+
)
|
|
150
|
+
state: dict[str, Any] = {}
|
|
151
|
+
if hasattr(mailable, "__dict__"):
|
|
152
|
+
for k, v in vars(mailable).items():
|
|
153
|
+
if not k.startswith("_"):
|
|
154
|
+
state[k] = v
|
|
155
|
+
self.data["mailable_state"] = state
|
|
156
|
+
self.data["mailer_name"] = mailable.mailer_name
|
|
157
|
+
|
|
158
|
+
def _resolve_mailable(self) -> Mailable:
|
|
159
|
+
if self.mailable is not None:
|
|
160
|
+
return self.mailable
|
|
161
|
+
|
|
162
|
+
mailable_class_str = self.data.get("mailable_class")
|
|
163
|
+
if not mailable_class_str or not isinstance(mailable_class_str, str):
|
|
164
|
+
raise MailValidationError(
|
|
165
|
+
"SendQueuedMailableJob missing 'mailable_class' in payload"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
mod_name, cls_name = parse_class_path(
|
|
169
|
+
mailable_class_str, exc_cls=MailValidationError
|
|
170
|
+
)
|
|
171
|
+
if not is_trusted_class_path(
|
|
172
|
+
mailable_class_str,
|
|
173
|
+
mod_name,
|
|
174
|
+
trusted_namespaces=Job.get_trusted_namespaces(),
|
|
175
|
+
trusted_classes=self._trusted_mailable_classes,
|
|
176
|
+
class_name=cls_name,
|
|
177
|
+
):
|
|
178
|
+
raise MailValidationError(
|
|
179
|
+
f"Mailable class '{mailable_class_str}' from module '{mod_name}' is not in trusted namespaces"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
module = importlib.import_module(mod_name)
|
|
183
|
+
target_cls = getattr(module, cls_name, None)
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
target_cls is None
|
|
187
|
+
or not isinstance(target_cls, type)
|
|
188
|
+
or not issubclass(target_cls, Mailable)
|
|
189
|
+
):
|
|
190
|
+
raise MailValidationError(
|
|
191
|
+
f"Class '{mailable_class_str}' is not a valid subclass of Mailable"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
instance = target_cls.__new__(target_cls)
|
|
195
|
+
state = self.data.get("mailable_state", {})
|
|
196
|
+
if state is not None:
|
|
197
|
+
if not isinstance(state, dict):
|
|
198
|
+
raise MailValidationError(
|
|
199
|
+
"SendQueuedMailableJob 'mailable_state' must be a dictionary"
|
|
200
|
+
)
|
|
201
|
+
allowed_fields = _allowed_state_fields(target_cls)
|
|
202
|
+
for key, value in state.items():
|
|
203
|
+
if not isinstance(key, str) or key.startswith("_"):
|
|
204
|
+
raise MailValidationError(
|
|
205
|
+
f"Disallowed attribute '{key}' in mailable_state"
|
|
206
|
+
)
|
|
207
|
+
existing = inspect.getattr_static(target_cls, key, _MISSING)
|
|
208
|
+
if existing is not _MISSING and (
|
|
209
|
+
callable(existing)
|
|
210
|
+
or (
|
|
211
|
+
(hasattr(existing, "__get__") or hasattr(existing, "__set__"))
|
|
212
|
+
and not isinstance(existing, types.MemberDescriptorType)
|
|
213
|
+
)
|
|
214
|
+
):
|
|
215
|
+
raise MailValidationError(
|
|
216
|
+
f"Disallowed descriptor or callable attribute '{key}' in mailable_state"
|
|
217
|
+
)
|
|
218
|
+
if key not in allowed_fields:
|
|
219
|
+
raise MailValidationError(
|
|
220
|
+
f"Undeclared attribute '{key}' in mailable_state for {target_cls.__name__}"
|
|
221
|
+
)
|
|
222
|
+
setattr(instance, key, value)
|
|
223
|
+
|
|
224
|
+
mailer_name = self.data.get("mailer_name")
|
|
225
|
+
if mailer_name is not None:
|
|
226
|
+
instance.mailer_name = str(mailer_name)
|
|
227
|
+
|
|
228
|
+
self.mailable = instance
|
|
229
|
+
return instance
|
|
230
|
+
|
|
231
|
+
async def handle(self) -> None:
|
|
232
|
+
mailable_label = (
|
|
233
|
+
self.mailable.__class__.__name__
|
|
234
|
+
if self.mailable is not None
|
|
235
|
+
else str(self.data.get("mailable_class", "unknown"))
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
mailable = self._resolve_mailable()
|
|
240
|
+
mailable_label = mailable.__class__.__name__
|
|
241
|
+
message = await mailable.build_message()
|
|
242
|
+
manager = MailManager.get_instance()
|
|
243
|
+
result = await manager.send(message, mailer=mailable.mailer_name)
|
|
244
|
+
if not result.success:
|
|
245
|
+
raise MailTransportError(
|
|
246
|
+
f"Email delivery via {result.provider} was not successful: rejected={result.rejected}"
|
|
247
|
+
)
|
|
248
|
+
except (MailConnectionError, MailRateLimitError, MailTimeoutError) as exc:
|
|
249
|
+
# Transient error: re-raise to trigger queue retry
|
|
250
|
+
logger.warning(
|
|
251
|
+
"Transient delivery failure for mailable %s: %s (attempt %d/%d)",
|
|
252
|
+
mailable_label,
|
|
253
|
+
exc,
|
|
254
|
+
self.attempts,
|
|
255
|
+
self.tries,
|
|
256
|
+
)
|
|
257
|
+
raise
|
|
258
|
+
except (
|
|
259
|
+
MailValidationError,
|
|
260
|
+
MailAuthenticationError,
|
|
261
|
+
MailConfigurationError,
|
|
262
|
+
):
|
|
263
|
+
# Permanent error: exhaust retries so QueueWorker sends the job to DLQ instead of looping
|
|
264
|
+
logger.exception(
|
|
265
|
+
"Permanent delivery failure for mailable %s",
|
|
266
|
+
mailable_label,
|
|
267
|
+
)
|
|
268
|
+
self.attempts = max(self.attempts, self.tries)
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
async def failed(self, exception: BaseException) -> None:
|
|
272
|
+
logger.error(
|
|
273
|
+
"Permanent failure delivering mailable %s: %s",
|
|
274
|
+
self.mailable.__class__.__name__ if self.mailable else "unknown",
|
|
275
|
+
exception,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
__all__ = ["SendQueuedMailableJob"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.core.providers.base import ServiceProvider
|
|
4
|
+
from swiftpy.mail.manager import MailManager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MailServiceProvider(ServiceProvider):
|
|
8
|
+
"""
|
|
9
|
+
Registers the MailManager singleton into the application DI container.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def register(self) -> None:
|
|
13
|
+
self.container.singleton(MailManager)
|
|
14
|
+
|
|
15
|
+
def boot(self) -> None:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["MailServiceProvider"]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import html
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from swiftpy.mail.exceptions import MailValidationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Jinja2Module(Protocol):
|
|
16
|
+
FileSystemLoader: Any
|
|
17
|
+
select_autoescape: Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_jinja_sandbox_env_cls: Callable[..., Any] | None
|
|
21
|
+
jinja2: _Jinja2Module | None
|
|
22
|
+
try:
|
|
23
|
+
import jinja2 as _jinja2
|
|
24
|
+
from jinja2.sandbox import SandboxedEnvironment as _SandboxedEnvironment
|
|
25
|
+
|
|
26
|
+
jinja2 = _jinja2
|
|
27
|
+
_jinja_sandbox_env_cls = _SandboxedEnvironment
|
|
28
|
+
except ImportError:
|
|
29
|
+
jinja2 = None
|
|
30
|
+
_jinja_sandbox_env_cls = None
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("swiftpy.mail.templates")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class RenderedEmail:
|
|
37
|
+
"""Represents the rendered output of an email template."""
|
|
38
|
+
|
|
39
|
+
html: str | None = None
|
|
40
|
+
text: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_VAR_PATTERN = re.compile(r"\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MailTemplateRenderer:
|
|
47
|
+
"""
|
|
48
|
+
Renders dual HTML and plain-text email templates with automatic escaping.
|
|
49
|
+
Uses Jinja2 if available; otherwise falls back to a safe built-in formatter.
|
|
50
|
+
Protects against path traversal, handles nested variables, and escapes quotes.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_instance: MailTemplateRenderer | None = None
|
|
54
|
+
|
|
55
|
+
def __init__(self, template_dir: str | Path = "views") -> None:
|
|
56
|
+
self.template_dir = Path(template_dir)
|
|
57
|
+
self._jinja_env: Any = None
|
|
58
|
+
if jinja2 is not None and _jinja_sandbox_env_cls is not None:
|
|
59
|
+
self._jinja_env = _jinja_sandbox_env_cls(
|
|
60
|
+
loader=jinja2.FileSystemLoader(str(self.template_dir)),
|
|
61
|
+
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
|
62
|
+
enable_async=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def get_instance(cls) -> MailTemplateRenderer:
|
|
67
|
+
if cls._instance is None:
|
|
68
|
+
cls._instance = cls()
|
|
69
|
+
return cls._instance
|
|
70
|
+
|
|
71
|
+
def _resolve_template_path(self, base_name: str, ext: str) -> Path:
|
|
72
|
+
"""Resolve template file path and prevent directory traversal attacks."""
|
|
73
|
+
candidate = self.template_dir / f"{base_name}.{ext}"
|
|
74
|
+
try:
|
|
75
|
+
resolved_candidate = candidate.resolve()
|
|
76
|
+
resolved_dir = self.template_dir.resolve()
|
|
77
|
+
if not resolved_candidate.is_relative_to(resolved_dir):
|
|
78
|
+
raise MailValidationError(
|
|
79
|
+
f"Template path '{base_name}' traverses outside template directory"
|
|
80
|
+
)
|
|
81
|
+
return resolved_candidate
|
|
82
|
+
except (ValueError, RuntimeError) as exc:
|
|
83
|
+
raise MailValidationError(
|
|
84
|
+
f"Invalid template path '{base_name}': {exc}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
async def render(
|
|
88
|
+
self,
|
|
89
|
+
template_name: str,
|
|
90
|
+
context: dict[str, Any],
|
|
91
|
+
) -> RenderedEmail:
|
|
92
|
+
"""Render both HTML and plain-text templates if present."""
|
|
93
|
+
base_name = template_name
|
|
94
|
+
if base_name.endswith((".html", ".txt")):
|
|
95
|
+
base_name = base_name.rsplit(".", 1)[0]
|
|
96
|
+
|
|
97
|
+
# Verify traversal safety
|
|
98
|
+
html_path = self._resolve_template_path(base_name, "html")
|
|
99
|
+
text_path = self._resolve_template_path(base_name, "txt")
|
|
100
|
+
|
|
101
|
+
html_content: str | None = None
|
|
102
|
+
text_content: str | None = None
|
|
103
|
+
|
|
104
|
+
if self._jinja_env is not None:
|
|
105
|
+
html_template_file = f"{base_name}.html"
|
|
106
|
+
text_template_file = f"{base_name}.txt"
|
|
107
|
+
|
|
108
|
+
if await asyncio.to_thread(html_path.is_file):
|
|
109
|
+
template = await asyncio.to_thread(
|
|
110
|
+
self._jinja_env.get_template, html_template_file
|
|
111
|
+
)
|
|
112
|
+
html_content = await template.render_async(context)
|
|
113
|
+
|
|
114
|
+
if await asyncio.to_thread(text_path.is_file):
|
|
115
|
+
template = await asyncio.to_thread(
|
|
116
|
+
self._jinja_env.get_template, text_template_file
|
|
117
|
+
)
|
|
118
|
+
text_content = await template.render_async(context)
|
|
119
|
+
else:
|
|
120
|
+
if await asyncio.to_thread(html_path.is_file):
|
|
121
|
+
raw = await asyncio.to_thread(html_path.read_text, encoding="utf-8")
|
|
122
|
+
html_content = self._render_string(raw, context, escape_html=True)
|
|
123
|
+
|
|
124
|
+
if await asyncio.to_thread(text_path.is_file):
|
|
125
|
+
raw = await asyncio.to_thread(text_path.read_text, encoding="utf-8")
|
|
126
|
+
text_content = self._render_string(raw, context, escape_html=False)
|
|
127
|
+
|
|
128
|
+
return RenderedEmail(html=html_content, text=text_content)
|
|
129
|
+
|
|
130
|
+
def render_string(
|
|
131
|
+
self,
|
|
132
|
+
template_str: str,
|
|
133
|
+
context: dict[str, Any],
|
|
134
|
+
escape_html: bool = True,
|
|
135
|
+
) -> str:
|
|
136
|
+
"""Render a raw template string with context values."""
|
|
137
|
+
return self._render_string(template_str, context, escape_html=escape_html)
|
|
138
|
+
|
|
139
|
+
def _render_string(
|
|
140
|
+
self,
|
|
141
|
+
template_str: str,
|
|
142
|
+
context: dict[str, Any],
|
|
143
|
+
escape_html: bool,
|
|
144
|
+
) -> str:
|
|
145
|
+
def _resolve_nested(path: str) -> Any:
|
|
146
|
+
parts = path.split(".")
|
|
147
|
+
curr: Any = context
|
|
148
|
+
for part in parts:
|
|
149
|
+
if part.startswith("_"):
|
|
150
|
+
raise MailValidationError(
|
|
151
|
+
f"Access to private or dunder template attribute '{part}' is forbidden"
|
|
152
|
+
)
|
|
153
|
+
if isinstance(curr, dict) and part in curr:
|
|
154
|
+
curr = curr[part]
|
|
155
|
+
elif hasattr(curr, part):
|
|
156
|
+
curr = getattr(curr, part)
|
|
157
|
+
else:
|
|
158
|
+
return None
|
|
159
|
+
return curr
|
|
160
|
+
|
|
161
|
+
def _replace(match: re.Match[str]) -> str:
|
|
162
|
+
var_path = match.group(1)
|
|
163
|
+
val = _resolve_nested(var_path)
|
|
164
|
+
if val is not None:
|
|
165
|
+
val_str = str(val)
|
|
166
|
+
return html.escape(val_str, quote=True) if escape_html else val_str
|
|
167
|
+
# Missing variable renders as empty string to avoid leaking placeholders
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
return _VAR_PATTERN.sub(_replace, template_str)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = ["MailTemplateRenderer", "RenderedEmail"]
|