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,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,5 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.mail.queue.job import SendQueuedMailableJob
4
+
5
+ __all__ = ["SendQueuedMailableJob"]
@@ -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,5 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.mail.templates.renderer import MailTemplateRenderer, RenderedEmail
4
+
5
+ __all__ = ["MailTemplateRenderer", "RenderedEmail"]
@@ -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"]