kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.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)
|
kstlib/mail/transport.py
ADDED
|
@@ -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
|
+
]
|