kstlib 0.0.1a0__py3-none-any.whl → 1.0.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 (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,248 @@
1
+ """Filesystem guard helpers dedicated to the mail module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, MutableMapping
6
+ from dataclasses import dataclass, replace
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ from kstlib.mail.exceptions import MailValidationError
11
+ from kstlib.secure import RELAXED_POLICY, STRICT_POLICY, GuardPolicy, PathGuardrails
12
+
13
+ __all__ = [
14
+ "MailExternalOverrides",
15
+ "MailFilesystemGuards",
16
+ "MailGuardRootsOverrides",
17
+ ]
18
+
19
+
20
+ _DEFAULT_CACHE_ROOT = Path.home() / ".cache" / "kstlib" / "mail"
21
+ MappingSection = Mapping[str, Any] | MutableMapping[str, Any]
22
+ OptionalMappingSection = MappingSection | None
23
+ ConfigLoader = Callable[..., MappingSection | None]
24
+ ConfigNotLoadedError: type[Exception]
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class MailGuardRootsOverrides:
29
+ """Optional overrides for guardrail root directories."""
30
+
31
+ attachments: str | Path | None = None
32
+ inline: str | Path | None = None
33
+ templates: str | Path | None = None
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class MailExternalOverrides:
38
+ """Optional overrides for external access allowances."""
39
+
40
+ attachments: bool | None = None
41
+ templates: bool | None = None
42
+
43
+
44
+ try:
45
+ from kstlib.config import get_config as _imported_get_config
46
+ except ImportError: # pragma: no cover - config module optional at import time
47
+ get_config: ConfigLoader | None = None
48
+ else:
49
+ get_config = cast("ConfigLoader", _imported_get_config)
50
+
51
+ try:
52
+ from kstlib.config.exceptions import ConfigNotLoadedError as _ImportedConfigNotLoadedError
53
+ except ImportError: # pragma: no cover - config module optional at import time
54
+
55
+ class _FallbackConfigNotLoadedError(RuntimeError):
56
+ """Fallback error raised when the config subsystem is unavailable."""
57
+
58
+ ConfigNotLoadedError = _FallbackConfigNotLoadedError
59
+ else:
60
+ ConfigNotLoadedError = _ImportedConfigNotLoadedError
61
+
62
+
63
+ class MailFilesystemGuards:
64
+ """Resolve mail templates and attachments using secure guardrails.
65
+
66
+ Example:
67
+ >>> guards = MailFilesystemGuards.default() # doctest: +SKIP
68
+ >>> safe_attachment = guards.resolve_attachment("reports/daily.csv") # doctest: +SKIP
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ attachments: PathGuardrails,
75
+ inline: PathGuardrails | None = None,
76
+ templates: PathGuardrails | None = None,
77
+ ) -> None:
78
+ """Initialise guardrails, defaulting inline/templates to attachment settings."""
79
+ self._attachments = attachments
80
+ self._inline = inline or attachments
81
+ self._templates = templates or attachments
82
+
83
+ @property
84
+ def attachments_root(self) -> Path:
85
+ """Return the root used for attachments (resolved path)."""
86
+ return self._attachments.root
87
+
88
+ @property
89
+ def inline_root(self) -> Path:
90
+ """Return the root dedicated to inline resources."""
91
+ return self._inline.root
92
+
93
+ @property
94
+ def templates_root(self) -> Path:
95
+ """Return the root used for templates (resolved path)."""
96
+ return self._templates.root
97
+
98
+ @classmethod
99
+ def default(cls) -> MailFilesystemGuards:
100
+ """Construct guards from the loaded configuration or fallback defaults."""
101
+ config = cls._load_config_section()
102
+ return cls.from_sources(config=config)
103
+
104
+ @classmethod
105
+ def from_sources(
106
+ cls,
107
+ *,
108
+ config: OptionalMappingSection = None,
109
+ roots: MailGuardRootsOverrides | None = None,
110
+ external: MailExternalOverrides | None = None,
111
+ policy: GuardPolicy | None = None,
112
+ ) -> MailFilesystemGuards:
113
+ """Build guards from optional config mappings and overrides."""
114
+ section = cls._extract_section(config)
115
+ policy = cls._derive_policy(section, policy)
116
+ attachments_guard, inline_guard, templates_guard = cls._build_guardrails(
117
+ section=section,
118
+ policy=policy,
119
+ roots=roots,
120
+ external=external,
121
+ )
122
+ return cls(attachments=attachments_guard, inline=inline_guard, templates=templates_guard)
123
+
124
+ @classmethod
125
+ def relaxed_for_testing(cls, root: Path) -> MailFilesystemGuards:
126
+ """Helper for tests/examples that need a temporary relaxed environment."""
127
+ policy = RELAXED_POLICY
128
+ attachments_guard = PathGuardrails(root / "attachments", policy=policy)
129
+ inline_guard = PathGuardrails(root / "inline", policy=policy)
130
+ templates_guard = PathGuardrails(root / "templates", policy=policy)
131
+ return cls(attachments=attachments_guard, inline=inline_guard, templates=templates_guard)
132
+
133
+ def _resolve_path(self, guardrail: PathGuardrails, candidate: str | Path) -> Path:
134
+ """Resolve *candidate* using the given guardrail, wrapping exceptions."""
135
+ try:
136
+ return guardrail.resolve_file(candidate)
137
+ except Exception as exc: # pragma: no cover - mapped error
138
+ raise MailValidationError(str(exc)) from exc
139
+
140
+ def resolve_attachment(self, candidate: str | Path) -> Path:
141
+ """Resolve *candidate* as a secure attachment path."""
142
+ return self._resolve_path(self._attachments, candidate)
143
+
144
+ def resolve_inline(self, candidate: str | Path) -> Path:
145
+ """Resolve *candidate* as a secure inline resource path."""
146
+ return self._resolve_path(self._inline, candidate)
147
+
148
+ def resolve_template(self, candidate: str | Path) -> Path:
149
+ """Resolve *candidate* as a secure template file path."""
150
+ return self._resolve_path(self._templates, candidate)
151
+
152
+ @staticmethod
153
+ def _extract_section(config: OptionalMappingSection) -> Mapping[str, Any]:
154
+ if not config:
155
+ return {}
156
+ if "filesystem" in config:
157
+ subsection = config["filesystem"]
158
+ if not isinstance(subsection, Mapping | MutableMapping):
159
+ return {}
160
+ return dict(subsection)
161
+ return dict(config)
162
+
163
+ @staticmethod
164
+ def _derive_policy(section: Mapping[str, Any], policy: GuardPolicy | None) -> GuardPolicy:
165
+ baseline_policy = policy or STRICT_POLICY
166
+ return replace(
167
+ baseline_policy,
168
+ auto_create_root=bool(section.get("auto_create_roots", baseline_policy.auto_create_root)),
169
+ enforce_permissions=bool(section.get("enforce_permissions", baseline_policy.enforce_permissions)),
170
+ max_permission_octal=int(section.get("max_permission_octal", baseline_policy.max_permission_octal)),
171
+ )
172
+
173
+ @staticmethod
174
+ def _resolve_roots(
175
+ section: Mapping[str, Any],
176
+ overrides: MailGuardRootsOverrides | None,
177
+ ) -> tuple[Path, Path, Path]:
178
+ attachments_root = Path(
179
+ (overrides.attachments if overrides else None)
180
+ or section.get("attachments_root")
181
+ or (_DEFAULT_CACHE_ROOT / "attachments")
182
+ )
183
+ inline_root = Path((overrides.inline if overrides else None) or section.get("inline_root") or attachments_root)
184
+ templates_root = Path(
185
+ (overrides.templates if overrides else None)
186
+ or section.get("templates_root")
187
+ or (_DEFAULT_CACHE_ROOT / "templates")
188
+ )
189
+ return attachments_root, inline_root, templates_root
190
+
191
+ @staticmethod
192
+ def _resolve_external_flags(
193
+ section: Mapping[str, Any],
194
+ overrides: MailExternalOverrides | None,
195
+ ) -> tuple[bool, bool]:
196
+ if overrides and overrides.attachments is not None:
197
+ allow_attachments = bool(overrides.attachments)
198
+ else:
199
+ allow_attachments = bool(section.get("allow_external_attachments", False))
200
+
201
+ if overrides and overrides.templates is not None:
202
+ allow_templates = bool(overrides.templates)
203
+ else:
204
+ allow_templates = bool(section.get("allow_external_templates", False))
205
+
206
+ return allow_attachments, allow_templates
207
+
208
+ @staticmethod
209
+ def _build_guardrails(
210
+ *,
211
+ section: Mapping[str, Any],
212
+ policy: GuardPolicy,
213
+ roots: MailGuardRootsOverrides | None,
214
+ external: MailExternalOverrides | None,
215
+ ) -> tuple[PathGuardrails, PathGuardrails, PathGuardrails]:
216
+ """Compose guardrail instances while keeping calling sites lean."""
217
+ attachments_root, inline_root, templates_root = MailFilesystemGuards._resolve_roots(section, roots)
218
+ allow_external_attachments, allow_external_templates = MailFilesystemGuards._resolve_external_flags(
219
+ section,
220
+ external,
221
+ )
222
+
223
+ attachments_policy = replace(policy, allow_external=allow_external_attachments)
224
+ inline_policy = replace(policy, allow_external=allow_external_attachments)
225
+ templates_policy = replace(policy, allow_external=allow_external_templates)
226
+
227
+ attachments_guard = PathGuardrails(attachments_root, policy=attachments_policy)
228
+ inline_guard = PathGuardrails(inline_root, policy=inline_policy)
229
+ templates_guard = PathGuardrails(templates_root, policy=templates_policy)
230
+ return attachments_guard, inline_guard, templates_guard
231
+
232
+ @staticmethod
233
+ def _load_config_section() -> Mapping[str, Any] | None:
234
+ if get_config is None:
235
+ return None
236
+ try:
237
+ conf = get_config()
238
+ except ConfigNotLoadedError:
239
+ return None
240
+ mail_conf = cast("OptionalMappingSection", conf.get("mail") if conf else None)
241
+ if not mail_conf:
242
+ return None
243
+ fs_conf_raw = mail_conf.get("filesystem")
244
+ if fs_conf_raw is None:
245
+ return None
246
+ if not isinstance(fs_conf_raw, Mapping | MutableMapping):
247
+ return None
248
+ return dict(fs_conf_raw)
@@ -0,0 +1,224 @@
1
+ """Transport interfaces for mail delivery.
2
+
3
+ Provides both sync and async transport abstractions for sending emails.
4
+ Sync transports can be wrapped for async usage via :class:`AsyncTransportWrapper`.
5
+
6
+ Examples:
7
+ Using a sync transport directly::
8
+
9
+ from kstlib.mail.transports import SMTPTransport
10
+
11
+ transport = SMTPTransport(host="smtp.example.com", port=587)
12
+ transport.send(message)
13
+
14
+ Wrapping a sync transport for async usage::
15
+
16
+ from kstlib.mail.transport import AsyncTransportWrapper
17
+ from kstlib.mail.transports import SMTPTransport
18
+
19
+ smtp = SMTPTransport(host="smtp.example.com", port=587)
20
+ async_transport = AsyncTransportWrapper(smtp)
21
+ await async_transport.send(message)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging # noqa: TC003 - used for type hint in function signature
28
+ from abc import ABC, abstractmethod
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from kstlib.mail.exceptions import MailTransportError
32
+
33
+ if TYPE_CHECKING:
34
+ from concurrent.futures import ThreadPoolExecutor
35
+ from email.message import EmailMessage
36
+
37
+ import httpx
38
+
39
+
40
+ class MailTransport(ABC):
41
+ """Abstract sync transport for delivering emails.
42
+
43
+ Subclass this for synchronous transport implementations like SMTP.
44
+
45
+ Examples:
46
+ Implementing a custom sync transport::
47
+
48
+ class MyTransport(MailTransport):
49
+ def send(self, message: EmailMessage) -> None:
50
+ # Send the message
51
+ pass
52
+ """
53
+
54
+ @abstractmethod
55
+ def send(self, message: EmailMessage) -> None:
56
+ """Deliver the message to the underlying service.
57
+
58
+ Args:
59
+ message: The email message to send.
60
+
61
+ Raises:
62
+ MailTransportError: If delivery fails.
63
+ """
64
+
65
+
66
+ class AsyncMailTransport(ABC):
67
+ """Abstract async transport for delivering emails.
68
+
69
+ Subclass this for asynchronous transport implementations like HTTP APIs.
70
+
71
+ Examples:
72
+ Implementing a custom async transport::
73
+
74
+ class MyAsyncTransport(AsyncMailTransport):
75
+ async def send(self, message: EmailMessage) -> None:
76
+ async with httpx.AsyncClient() as client:
77
+ await client.post(...)
78
+ """
79
+
80
+ @abstractmethod
81
+ async def send(self, message: EmailMessage) -> None:
82
+ """Deliver the message asynchronously.
83
+
84
+ Args:
85
+ message: The email message to send.
86
+
87
+ Raises:
88
+ MailTransportError: If delivery fails.
89
+ """
90
+
91
+
92
+ class AsyncTransportWrapper(AsyncMailTransport):
93
+ """Wrap a sync transport for async usage.
94
+
95
+ Executes the sync transport's send method in a thread pool executor
96
+ to avoid blocking the event loop.
97
+
98
+ Args:
99
+ transport: The sync transport to wrap.
100
+ executor: Optional thread pool executor. If None, uses the default.
101
+
102
+ Examples:
103
+ Wrapping an SMTP transport::
104
+
105
+ from kstlib.mail.transport import AsyncTransportWrapper
106
+ from kstlib.mail.transports import SMTPTransport
107
+
108
+ smtp = SMTPTransport(host="smtp.example.com", port=587)
109
+ async_smtp = AsyncTransportWrapper(smtp)
110
+
111
+ # Now usable in async context
112
+ await async_smtp.send(message)
113
+
114
+ With custom executor::
115
+
116
+ from concurrent.futures import ThreadPoolExecutor
117
+
118
+ executor = ThreadPoolExecutor(max_workers=2)
119
+ async_smtp = AsyncTransportWrapper(smtp, executor=executor)
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ transport: MailTransport,
125
+ *,
126
+ executor: ThreadPoolExecutor | None = None,
127
+ ) -> None:
128
+ """Initialize the async wrapper.
129
+
130
+ Args:
131
+ transport: The sync transport to wrap.
132
+ executor: Optional custom thread pool executor.
133
+ """
134
+ self._transport = transport
135
+ self._executor = executor
136
+
137
+ @property
138
+ def transport(self) -> MailTransport:
139
+ """Return the wrapped sync transport."""
140
+ return self._transport
141
+
142
+ async def send(self, message: EmailMessage) -> None:
143
+ """Send message asynchronously via the wrapped transport.
144
+
145
+ Runs the sync transport's send method in a thread pool to avoid
146
+ blocking the async event loop.
147
+
148
+ Args:
149
+ message: The email message to send.
150
+
151
+ Raises:
152
+ MailTransportError: If the underlying transport fails.
153
+ """
154
+ loop = asyncio.get_running_loop()
155
+ await loop.run_in_executor(
156
+ self._executor,
157
+ self._transport.send,
158
+ message,
159
+ )
160
+
161
+
162
+ def handle_http_error_response(
163
+ response: httpx.Response,
164
+ service_name: str,
165
+ logger: logging.Logger,
166
+ *,
167
+ extract_code: bool = False,
168
+ ) -> None:
169
+ """Parse HTTP error response and raise MailTransportError.
170
+
171
+ Shared utility for HTTP-based mail transports (Gmail API, Resend, etc.).
172
+ Extracts error details from JSON response body when available.
173
+
174
+ Args:
175
+ response: The HTTP error response from the API.
176
+ service_name: Name of the service for error messages (e.g., "Gmail", "Resend").
177
+ logger: Logger instance for warning messages.
178
+ extract_code: If True, extract error code from response body (Gmail style).
179
+ If False, use HTTP status code only (Resend style).
180
+
181
+ Raises:
182
+ MailTransportError: Always raises with extracted error details.
183
+
184
+ Examples:
185
+ >>> import httpx # doctest: +SKIP
186
+ >>> response = httpx.Response(400, json={"error": "Bad request"}) # doctest: +SKIP
187
+ >>> handle_http_error_response(response, "MyAPI", logger) # doctest: +SKIP
188
+ Traceback (most recent call last):
189
+ ...
190
+ MailTransportError: MyAPI error: Bad request
191
+ """
192
+ error_msg: str
193
+ error_code: int | str = response.status_code
194
+
195
+ try:
196
+ data: dict[str, Any] = response.json()
197
+
198
+ if extract_code:
199
+ # Gmail-style: nested error object with code and message
200
+ error = data.get("error", {})
201
+ if isinstance(error, dict):
202
+ error_msg = error.get("message", str(error))
203
+ error_code = error.get("code", response.status_code)
204
+ else:
205
+ error_msg = str(error)
206
+ else:
207
+ # Resend-style: flat structure with message or error key
208
+ error_msg = data.get("message", data.get("error", "Unknown error"))
209
+ except Exception:
210
+ error_msg = response.text or f"HTTP {response.status_code}"
211
+
212
+ logger.warning("%s API error: %s (code=%s)", service_name, error_msg, error_code)
213
+
214
+ if extract_code:
215
+ raise MailTransportError(f"{service_name} API error ({error_code}): {error_msg}")
216
+ raise MailTransportError(f"{service_name} API error: {error_msg}")
217
+
218
+
219
+ __all__ = [
220
+ "AsyncMailTransport",
221
+ "AsyncTransportWrapper",
222
+ "MailTransport",
223
+ "handle_http_error_response",
224
+ ]
@@ -0,0 +1,19 @@
1
+ """Transport implementations for mail delivery.
2
+
3
+ Available transports:
4
+ - SMTPTransport: Standard SMTP protocol (sync)
5
+ - ResendTransport: Resend.com API (async)
6
+ - GmailTransport: Gmail API with OAuth2 (async)
7
+ """
8
+
9
+ from kstlib.mail.transports.gmail import GmailTransport
10
+ from kstlib.mail.transports.resend import ResendTransport
11
+ from kstlib.mail.transports.smtp import SMTPCredentials, SMTPSecurity, SMTPTransport
12
+
13
+ __all__ = [
14
+ "GmailTransport",
15
+ "ResendTransport",
16
+ "SMTPCredentials",
17
+ "SMTPSecurity",
18
+ "SMTPTransport",
19
+ ]