kstlib 2.2.1__tar.gz → 2.3.0__tar.gz
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-2.2.1/src/kstlib.egg-info → kstlib-2.3.0}/PKG-INFO +1 -1
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/__init__.py +5 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/__init__.py +2 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/loader.py +42 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/kstlib.conf.yml +23 -1
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/builder.py +362 -6
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/meta.py +1 -1
- {kstlib-2.2.1 → kstlib-2.3.0/src/kstlib.egg-info}/PKG-INFO +1 -1
- {kstlib-2.2.1 → kstlib-2.3.0}/LICENSE.md +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/MANIFEST.in +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/README.md +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/pyproject.toml +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/setup.cfg +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/__main__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/channels/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/channels/base.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/channels/email.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/channels/slack.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/manager.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/models.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/throttle.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/callback.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/check.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/config.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/errors.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/models.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/providers/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/providers/base.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/providers/oauth2.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/providers/oidc.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/session.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/token.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cache/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cache/decorator.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cache/strategies.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/app.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/check.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/common.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/login.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/logout.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/providers.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/status.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/token.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/whoami.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/config.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/attach.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/common.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/list_sessions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/logs.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/start.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/status.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/stop.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/rapi/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/rapi/call.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/rapi/list.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/rapi/show.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/common.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/decrypt.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/doctor.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/encrypt.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/shred.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/common.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/export.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/sops.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/aiosqlcipher.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/cipher.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/database.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/pool.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/helpers/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/helpers/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/helpers/time_trigger.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/limits.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/logging/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/logging/manager.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/filesystem.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transport.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/gmail.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/resend.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/ses.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/smtp.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/metrics/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/metrics/decorators.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/metrics/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/_styles.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/cell.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/config.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/delivery.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/image.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/kv.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/list.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/metric.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/monitoring.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/renderer.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/service.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/table.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/types.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/base.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/container.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/manager.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/models.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/tmux.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/validators.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/base.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/models.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/runner.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/_base.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/callable.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/python.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/shell.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/validators.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/py.typed +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/client.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/config.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/credentials.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/circuit_breaker.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/heartbeat.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/rate_limiter.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/shutdown.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/watchdog.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/models.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/base.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/environment.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/keyring.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/kms.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/kwargs.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/sops.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/resolver.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/sensitive.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secure/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secure/fs.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secure/permissions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ssl.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/chain.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/config.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/primitives.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/validators.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/panels.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/spinner.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/tables.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/dict.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/formatting.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/http_trace.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/lazy.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/secure_delete.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/serialization.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/text.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/validators.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/websocket/__init__.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/websocket/exceptions.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/websocket/manager.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/websocket/models.py +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib.egg-info/SOURCES.txt +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib.egg-info/dependency_links.txt +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib.egg-info/entry_points.txt +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib.egg-info/requires.txt +0 -0
- {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kstlib
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Config-driven helpers for Python projects (dynamic config, secure secrets, preset logging, and more…)
|
|
5
5
|
Author-email: Michel TRUONG <michel.truong@gmail.com>
|
|
6
6
|
Maintainer-email: Michel TRUONG <michel.truong@gmail.com>
|
|
@@ -69,6 +69,7 @@ __all__ = [
|
|
|
69
69
|
"ops",
|
|
70
70
|
"pipeline",
|
|
71
71
|
"rapi",
|
|
72
|
+
"reload_config",
|
|
72
73
|
"require_config",
|
|
73
74
|
"resilience",
|
|
74
75
|
"secrets",
|
|
@@ -117,6 +118,7 @@ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
|
|
117
118
|
"load_config": ("kstlib.config.loader", "load_config"),
|
|
118
119
|
"load_from_env": ("kstlib.config.loader", "load_from_env"),
|
|
119
120
|
"load_from_file": ("kstlib.config.loader", "load_from_file"),
|
|
121
|
+
"reload_config": ("kstlib.config.loader", "reload_config"),
|
|
120
122
|
"require_config": ("kstlib.config.loader", "require_config"),
|
|
121
123
|
# Logging
|
|
122
124
|
"LogManager": ("kstlib.logging", "LogManager"),
|
|
@@ -250,6 +252,9 @@ if TYPE_CHECKING:
|
|
|
250
252
|
from kstlib.config.loader import (
|
|
251
253
|
load_from_file as load_from_file,
|
|
252
254
|
)
|
|
255
|
+
from kstlib.config.loader import (
|
|
256
|
+
reload_config as reload_config,
|
|
257
|
+
)
|
|
253
258
|
from kstlib.config.loader import (
|
|
254
259
|
require_config as require_config,
|
|
255
260
|
)
|
|
@@ -34,6 +34,7 @@ from kstlib.config.loader import (
|
|
|
34
34
|
load_config,
|
|
35
35
|
load_from_env,
|
|
36
36
|
load_from_file,
|
|
37
|
+
reload_config,
|
|
37
38
|
require_config,
|
|
38
39
|
)
|
|
39
40
|
from kstlib.config.sops import (
|
|
@@ -71,6 +72,7 @@ __all__ = [
|
|
|
71
72
|
"load_config",
|
|
72
73
|
"load_from_env",
|
|
73
74
|
"load_from_file",
|
|
75
|
+
"reload_config",
|
|
74
76
|
"require_config",
|
|
75
77
|
"reset_decryptor",
|
|
76
78
|
]
|
|
@@ -1064,6 +1064,47 @@ def clear_config() -> None:
|
|
|
1064
1064
|
_default_loader.cache = None
|
|
1065
1065
|
|
|
1066
1066
|
|
|
1067
|
+
def reload_config(filename: str = CONFIG_FILENAME) -> Box:
|
|
1068
|
+
"""Force-reload the singleton configuration from disk.
|
|
1069
|
+
|
|
1070
|
+
Equivalent to ``clear_config()`` followed by ``get_config()``, but explicit
|
|
1071
|
+
and discoverable in a single import. Designed for interactive sessions
|
|
1072
|
+
(Jupyter, REPL) where the underlying YAML files have been edited and the
|
|
1073
|
+
cached config needs to be refreshed without restarting the kernel.
|
|
1074
|
+
|
|
1075
|
+
Args:
|
|
1076
|
+
filename: Config filename to search for. Defaults to
|
|
1077
|
+
``kstlib.conf.yml``.
|
|
1078
|
+
|
|
1079
|
+
Returns:
|
|
1080
|
+
Freshly loaded ``Box`` configuration object. The singleton cache is
|
|
1081
|
+
updated in-place, so subsequent ``get_config()`` calls return the same
|
|
1082
|
+
fresh object.
|
|
1083
|
+
|
|
1084
|
+
Raises:
|
|
1085
|
+
ConfigFileNotFoundError: If no configuration file is found in any
|
|
1086
|
+
search location.
|
|
1087
|
+
|
|
1088
|
+
Note:
|
|
1089
|
+
When to use which:
|
|
1090
|
+
|
|
1091
|
+
- ``reload_config()``: explicit one-shot refresh (Jupyter/REPL).
|
|
1092
|
+
- ``get_config(force_reload=True)``: same effect, but the intent is
|
|
1093
|
+
hidden in a kwarg.
|
|
1094
|
+
- ``clear_config()``: only flushes the cache; the next
|
|
1095
|
+
``get_config()`` call triggers the actual reload. Useful in tests
|
|
1096
|
+
that want to isolate the cache boundary.
|
|
1097
|
+
|
|
1098
|
+
Examples:
|
|
1099
|
+
>>> from kstlib.config import reload_config
|
|
1100
|
+
>>> cfg = reload_config() # doctest: +SKIP
|
|
1101
|
+
>>> cfg.mail.default # doctest: +SKIP
|
|
1102
|
+
'corporate'
|
|
1103
|
+
|
|
1104
|
+
"""
|
|
1105
|
+
return get_config(filename=filename, force_reload=True)
|
|
1106
|
+
|
|
1107
|
+
|
|
1067
1108
|
__all__ = [
|
|
1068
1109
|
"AutoDiscoveryConfig",
|
|
1069
1110
|
"ConfigLoader",
|
|
@@ -1072,5 +1113,6 @@ __all__ = [
|
|
|
1072
1113
|
"load_config",
|
|
1073
1114
|
"load_from_env",
|
|
1074
1115
|
"load_from_file",
|
|
1116
|
+
"reload_config",
|
|
1075
1117
|
"require_config",
|
|
1076
1118
|
]
|
|
@@ -358,6 +358,21 @@ mail:
|
|
|
358
358
|
# Leave null to force callers to pass transport= or preset= explicitly.
|
|
359
359
|
default: null
|
|
360
360
|
|
|
361
|
+
# SSL/TLS configuration for mail transports.
|
|
362
|
+
#
|
|
363
|
+
# Values here override the root ``ssl:`` section (bottom of this file)
|
|
364
|
+
# for mail only, and are themselves overridden by ``ssl_verify`` and
|
|
365
|
+
# ``ssl_ca_bundle`` keys set inside an individual preset. Each key
|
|
366
|
+
# cascades independently: you can set ``verify: false`` here and still
|
|
367
|
+
# provide ``ssl_ca_bundle`` at the preset level.
|
|
368
|
+
#
|
|
369
|
+
# Security: setting ``verify: false`` at any level emits a WARNING log
|
|
370
|
+
# at transport build time. Prefer ``ca_bundle: /path/to/private-ca.pem``
|
|
371
|
+
# for internal PKI rather than disabling verification outright.
|
|
372
|
+
ssl:
|
|
373
|
+
verify: true
|
|
374
|
+
ca_bundle: null
|
|
375
|
+
|
|
361
376
|
# Named transport presets. Each preset declares a "transport" field
|
|
362
377
|
# (smtp or resend) and backend-specific parameters. Define your own
|
|
363
378
|
# presets here and reference them via MailBuilder(preset="name").
|
|
@@ -368,11 +383,18 @@ mail:
|
|
|
368
383
|
# transport: smtp
|
|
369
384
|
# host: smtp-secure.corp.local
|
|
370
385
|
# port: 25
|
|
371
|
-
# login:
|
|
386
|
+
# login: svc_mail
|
|
372
387
|
# password: "secret"
|
|
373
388
|
# starttls: false
|
|
374
389
|
# ssl: false
|
|
375
390
|
# timeout: 30
|
|
391
|
+
# # Optional SSL overrides for this preset (highest priority in the cascade):
|
|
392
|
+
# ssl_verify: false
|
|
393
|
+
# ssl_ca_bundle: /etc/ssl/certs/corp-ca.pem
|
|
394
|
+
# # Optional envelope defaults (sender / reply_to only, never to/cc/bcc):
|
|
395
|
+
# defaults:
|
|
396
|
+
# sender: "Service Notifications <notify@corp.local>"
|
|
397
|
+
# reply_to: "Service Notifications <notify@corp.local>"
|
|
376
398
|
#
|
|
377
399
|
# transactional:
|
|
378
400
|
# transport: resend
|
|
@@ -9,6 +9,7 @@ import functools
|
|
|
9
9
|
import html
|
|
10
10
|
import inspect
|
|
11
11
|
import mimetypes
|
|
12
|
+
import ssl
|
|
12
13
|
import time
|
|
13
14
|
import traceback
|
|
14
15
|
from dataclasses import dataclass
|
|
@@ -17,8 +18,10 @@ from email.message import EmailMessage
|
|
|
17
18
|
from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypeVar, overload
|
|
18
19
|
|
|
19
20
|
from kstlib.limits import MailLimits, get_mail_limits
|
|
21
|
+
from kstlib.logging import get_logger
|
|
20
22
|
from kstlib.mail.exceptions import MailConfigurationError, MailTransportError, MailValidationError
|
|
21
23
|
from kstlib.mail.filesystem import MailFilesystemGuards
|
|
24
|
+
from kstlib.ssl import get_ssl_config, validate_ca_bundle_path
|
|
22
25
|
from kstlib.utils import (
|
|
23
26
|
EmailAddress,
|
|
24
27
|
ValidationError,
|
|
@@ -28,6 +31,8 @@ from kstlib.utils import (
|
|
|
28
31
|
replace_placeholders,
|
|
29
32
|
)
|
|
30
33
|
|
|
34
|
+
log = get_logger(__name__)
|
|
35
|
+
|
|
31
36
|
if TYPE_CHECKING:
|
|
32
37
|
from collections.abc import Callable, Iterable, Mapping
|
|
33
38
|
from pathlib import Path
|
|
@@ -44,6 +49,10 @@ R = TypeVar("R")
|
|
|
44
49
|
|
|
45
50
|
_DEFAULT_ENCODING = "utf-8"
|
|
46
51
|
|
|
52
|
+
# Envelope fields that may appear under ``mail.presets.<name>.defaults``.
|
|
53
|
+
# Any other key is logged once as a WARNING and ignored (forward-compat).
|
|
54
|
+
_KNOWN_PRESET_ENVELOPE_DEFAULTS: frozenset[str] = frozenset({"sender", "reply_to"})
|
|
55
|
+
|
|
47
56
|
|
|
48
57
|
@dataclass(frozen=True, slots=True)
|
|
49
58
|
class _InlineResource:
|
|
@@ -91,6 +100,30 @@ class MailBuilder:
|
|
|
91
100
|
4. ``None``: no transport. ``.build()`` still works. ``.send()`` raises
|
|
92
101
|
``MailConfigurationError``.
|
|
93
102
|
|
|
103
|
+
Preset envelope defaults:
|
|
104
|
+
A preset may declare a ``defaults`` subsection with ``sender`` and
|
|
105
|
+
``reply_to`` keys. These are applied automatically when the builder
|
|
106
|
+
is initialised with ``preset=`` or when the config's ``mail.default``
|
|
107
|
+
resolves to such a preset. User-provided values via ``.sender()`` or
|
|
108
|
+
``.reply_to()`` always override the preset defaults (user wins).
|
|
109
|
+
|
|
110
|
+
Deliberately scoped to ``sender`` and ``reply_to`` only:
|
|
111
|
+
``to``/``cc``/``bcc`` are excluded on purpose to prevent silent
|
|
112
|
+
accidental sends to the preset's audience. Unsupported keys inside
|
|
113
|
+
``defaults`` are logged once as a WARNING and ignored (forward
|
|
114
|
+
compatibility).
|
|
115
|
+
|
|
116
|
+
Example YAML::
|
|
117
|
+
|
|
118
|
+
mail:
|
|
119
|
+
presets:
|
|
120
|
+
corporate:
|
|
121
|
+
transport: smtp
|
|
122
|
+
host: smtp-secure.corp.local
|
|
123
|
+
defaults:
|
|
124
|
+
sender: "Service Notifications <notify@corp.local>"
|
|
125
|
+
reply_to: "Service Notifications <notify@corp.local>"
|
|
126
|
+
|
|
94
127
|
Example:
|
|
95
128
|
Build an email without sending (useful for inspection)::
|
|
96
129
|
|
|
@@ -114,10 +147,10 @@ class MailBuilder:
|
|
|
114
147
|
>>> mail = MailBuilder(transport=transport)
|
|
115
148
|
>>> # mail.sender(...).to(...).subject(...).message(...).send()
|
|
116
149
|
|
|
117
|
-
Config-driven via a named preset::
|
|
150
|
+
Config-driven via a named preset (defaults.sender is pre-filled)::
|
|
118
151
|
|
|
119
152
|
>>> mail = MailBuilder(preset="corporate") # doctest: +SKIP
|
|
120
|
-
>>> # mail.
|
|
153
|
+
>>> # mail.to(...).subject(...).message(...).send()
|
|
121
154
|
|
|
122
155
|
Config-driven via ``mail.default`` preset::
|
|
123
156
|
|
|
@@ -140,10 +173,13 @@ class MailBuilder:
|
|
|
140
173
|
Args:
|
|
141
174
|
transport: Explicit transport instance. Takes priority over ``preset``
|
|
142
175
|
and the config default. Backward compatible: passing this is
|
|
143
|
-
equivalent to the pre-preset API.
|
|
176
|
+
equivalent to the pre-preset API. When set, preset envelope
|
|
177
|
+
defaults are **not** applied.
|
|
144
178
|
preset: Name of a preset declared under ``mail.presets`` in
|
|
145
179
|
``kstlib.conf.yml``. Resolved immediately. Raises
|
|
146
180
|
``MailConfigurationError`` if the preset does not exist.
|
|
181
|
+
Any ``defaults.sender`` / ``defaults.reply_to`` declared on
|
|
182
|
+
the preset are applied right after transport resolution.
|
|
147
183
|
encoding: Character encoding for message bodies (default: utf-8).
|
|
148
184
|
filesystem: Filesystem guardrails for attachments, inline
|
|
149
185
|
resources, and templates.
|
|
@@ -151,15 +187,22 @@ class MailBuilder:
|
|
|
151
187
|
|
|
152
188
|
Raises:
|
|
153
189
|
MailConfigurationError: If ``preset`` is passed but does not
|
|
154
|
-
resolve to a valid preset in configuration
|
|
190
|
+
resolve to a valid preset in configuration, or if a preset
|
|
191
|
+
default has a non-string value.
|
|
192
|
+
MailValidationError: If a preset default holds an unparseable
|
|
193
|
+
email address.
|
|
155
194
|
|
|
156
195
|
"""
|
|
196
|
+
resolved_preset_name: str | None
|
|
157
197
|
if transport is not None:
|
|
158
198
|
self._transport: TransportLike | None = transport
|
|
199
|
+
resolved_preset_name = None
|
|
159
200
|
elif preset is not None:
|
|
160
201
|
self._transport = _build_transport_from_preset(preset)
|
|
202
|
+
resolved_preset_name = preset
|
|
161
203
|
else:
|
|
162
204
|
self._transport = _resolve_default_transport()
|
|
205
|
+
resolved_preset_name = _resolve_default_preset_name() if self._transport is not None else None
|
|
163
206
|
self._encoding = encoding
|
|
164
207
|
self._filesystem = filesystem or MailFilesystemGuards.default()
|
|
165
208
|
self._limits = limits or get_mail_limits()
|
|
@@ -174,6 +217,11 @@ class MailBuilder:
|
|
|
174
217
|
self._attachments: list[Path] = []
|
|
175
218
|
self._inline: list[_InlineResource] = []
|
|
176
219
|
|
|
220
|
+
if resolved_preset_name is not None:
|
|
221
|
+
envelope_defaults = _load_preset_envelope_defaults(resolved_preset_name)
|
|
222
|
+
if envelope_defaults:
|
|
223
|
+
self._apply_preset_envelope_defaults(envelope_defaults)
|
|
224
|
+
|
|
177
225
|
# ------------------------------------------------------------------
|
|
178
226
|
# Addressing
|
|
179
227
|
# ------------------------------------------------------------------
|
|
@@ -587,6 +635,38 @@ class MailBuilder:
|
|
|
587
635
|
except ValidationError as exc:
|
|
588
636
|
raise MailValidationError(str(exc)) from exc
|
|
589
637
|
|
|
638
|
+
def _apply_preset_envelope_defaults(self, defaults: Mapping[str, Any]) -> None:
|
|
639
|
+
"""Seed ``_sender`` / ``_reply_to`` from a preset's ``defaults`` section.
|
|
640
|
+
|
|
641
|
+
User-provided values via :meth:`sender` or :meth:`reply_to` overwrite
|
|
642
|
+
these seeds by the natural assignment pattern - no extra bookkeeping
|
|
643
|
+
required.
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
defaults: Envelope defaults dict, already filtered to the
|
|
647
|
+
supported keys by :func:`_load_preset_envelope_defaults`.
|
|
648
|
+
|
|
649
|
+
Raises:
|
|
650
|
+
MailConfigurationError: If a supported key is present with a
|
|
651
|
+
non-string value.
|
|
652
|
+
MailValidationError: If a supported key holds an unparseable
|
|
653
|
+
email address (propagated from :meth:`_parse_address`).
|
|
654
|
+
|
|
655
|
+
"""
|
|
656
|
+
sender = defaults.get("sender")
|
|
657
|
+
if sender is not None:
|
|
658
|
+
if not isinstance(sender, str):
|
|
659
|
+
msg = f"preset defaults.sender must be a string, got {type(sender).__name__}: {sender!r}"
|
|
660
|
+
raise MailConfigurationError(msg)
|
|
661
|
+
self._sender = self._parse_address(sender)
|
|
662
|
+
|
|
663
|
+
reply_to = defaults.get("reply_to")
|
|
664
|
+
if reply_to is not None:
|
|
665
|
+
if not isinstance(reply_to, str):
|
|
666
|
+
msg = f"preset defaults.reply_to must be a string, got {type(reply_to).__name__}: {reply_to!r}"
|
|
667
|
+
raise MailConfigurationError(msg)
|
|
668
|
+
self._reply_to = self._parse_address(reply_to)
|
|
669
|
+
|
|
590
670
|
def _parse_addresses(self, values: Iterable[str]) -> list[EmailAddress]:
|
|
591
671
|
try:
|
|
592
672
|
return normalize_address_list(values)
|
|
@@ -742,6 +822,83 @@ def _resolve_default_transport() -> TransportLike | None:
|
|
|
742
822
|
return _build_transport_from_preset(str(default_name))
|
|
743
823
|
|
|
744
824
|
|
|
825
|
+
def _resolve_default_preset_name() -> str | None:
|
|
826
|
+
"""Return the preset name referenced by ``mail.default``, or ``None``.
|
|
827
|
+
|
|
828
|
+
Swallows any config loading error and returns ``None`` so that
|
|
829
|
+
``MailBuilder()`` stays usable even when the config file is missing.
|
|
830
|
+
Mirrors the silent-empty contract of :func:`_load_mail_config`.
|
|
831
|
+
"""
|
|
832
|
+
try:
|
|
833
|
+
mail_cfg = _load_mail_config()
|
|
834
|
+
except MailConfigurationError:
|
|
835
|
+
return None
|
|
836
|
+
if mail_cfg is None or not hasattr(mail_cfg, "get"):
|
|
837
|
+
return None
|
|
838
|
+
default_name = mail_cfg.get("default")
|
|
839
|
+
if not default_name:
|
|
840
|
+
return None
|
|
841
|
+
return str(default_name)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _load_preset_envelope_defaults(preset_name: str) -> dict[str, Any]:
|
|
845
|
+
"""Read the ``defaults`` subsection of a named mail preset.
|
|
846
|
+
|
|
847
|
+
Returns an empty dict silently when the config is unavailable, the
|
|
848
|
+
preset does not exist, the preset has no ``defaults`` section, or the
|
|
849
|
+
section is empty / of the wrong shape. Only keys in
|
|
850
|
+
:data:`_KNOWN_PRESET_ENVELOPE_DEFAULTS` are returned; any other key is
|
|
851
|
+
logged once as a WARNING (batched, one log per call) and ignored. This
|
|
852
|
+
keeps old YAML files working when kstlib later adds new supported keys.
|
|
853
|
+
|
|
854
|
+
Args:
|
|
855
|
+
preset_name: Name of the preset declared under ``mail.presets``.
|
|
856
|
+
|
|
857
|
+
Returns:
|
|
858
|
+
Dict containing only the supported keys present in the preset
|
|
859
|
+
defaults. Empty dict if nothing is configured.
|
|
860
|
+
|
|
861
|
+
Examples:
|
|
862
|
+
>>> _load_preset_envelope_defaults("corporate") # doctest: +SKIP
|
|
863
|
+
{'sender': 'Service Notifications <notify@corp.local>'}
|
|
864
|
+
|
|
865
|
+
"""
|
|
866
|
+
try:
|
|
867
|
+
mail_cfg = _load_mail_config()
|
|
868
|
+
except MailConfigurationError:
|
|
869
|
+
return {}
|
|
870
|
+
if mail_cfg is None or not hasattr(mail_cfg, "get"):
|
|
871
|
+
return {}
|
|
872
|
+
presets = mail_cfg.get("presets")
|
|
873
|
+
if presets is None or not hasattr(presets, "get"):
|
|
874
|
+
return {}
|
|
875
|
+
preset_cfg = presets.get(preset_name)
|
|
876
|
+
if preset_cfg is None or not hasattr(preset_cfg, "get"):
|
|
877
|
+
return {}
|
|
878
|
+
raw_defaults = preset_cfg.get("defaults")
|
|
879
|
+
if raw_defaults is None or not hasattr(raw_defaults, "items"):
|
|
880
|
+
return {}
|
|
881
|
+
|
|
882
|
+
known: dict[str, Any] = {}
|
|
883
|
+
unknown: list[str] = []
|
|
884
|
+
for key, value in raw_defaults.items():
|
|
885
|
+
key_str = str(key)
|
|
886
|
+
if key_str in _KNOWN_PRESET_ENVELOPE_DEFAULTS:
|
|
887
|
+
known[key_str] = value
|
|
888
|
+
else:
|
|
889
|
+
unknown.append(key_str)
|
|
890
|
+
|
|
891
|
+
if unknown:
|
|
892
|
+
log.warning(
|
|
893
|
+
"mail preset %r has unsupported defaults keys: %s. Supported keys are: %s. Unsupported keys are ignored.",
|
|
894
|
+
preset_name,
|
|
895
|
+
sorted(unknown),
|
|
896
|
+
sorted(_KNOWN_PRESET_ENVELOPE_DEFAULTS),
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
return known
|
|
900
|
+
|
|
901
|
+
|
|
745
902
|
def _build_transport_from_preset(preset_name: str) -> TransportLike:
|
|
746
903
|
"""Build a transport from a named preset in the configuration.
|
|
747
904
|
|
|
@@ -783,18 +940,213 @@ def _build_transport_from_preset(preset_name: str) -> TransportLike:
|
|
|
783
940
|
)
|
|
784
941
|
|
|
785
942
|
|
|
943
|
+
def _load_mail_ssl_section() -> Any | None:
|
|
944
|
+
"""Return the ``mail.ssl`` section of the configuration, or ``None``.
|
|
945
|
+
|
|
946
|
+
Isolates the defensive imports/lookups so :func:`_resolve_mail_ssl_config`
|
|
947
|
+
stays readable. Returns ``None`` whenever the section is missing or the
|
|
948
|
+
config loader cannot be reached.
|
|
949
|
+
"""
|
|
950
|
+
try:
|
|
951
|
+
mail_section = _load_mail_config()
|
|
952
|
+
except MailConfigurationError:
|
|
953
|
+
return None
|
|
954
|
+
if mail_section is None or not hasattr(mail_section, "get"):
|
|
955
|
+
return None
|
|
956
|
+
ssl_section = mail_section.get("ssl")
|
|
957
|
+
if ssl_section is None or not hasattr(ssl_section, "get"):
|
|
958
|
+
return None
|
|
959
|
+
return ssl_section
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _load_root_ssl_config() -> Any | None:
|
|
963
|
+
"""Return the root-level :class:`kstlib.ssl.SSLConfig`, or ``None``.
|
|
964
|
+
|
|
965
|
+
Swallows any loading error (missing config, unreadable YAML) and yields
|
|
966
|
+
``None`` so the caller keeps cascading to the Python default.
|
|
967
|
+
"""
|
|
968
|
+
try:
|
|
969
|
+
return get_ssl_config()
|
|
970
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
971
|
+
return None
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _cascade_mail_ssl_level(
|
|
975
|
+
verify: Any,
|
|
976
|
+
ca_bundle: Any,
|
|
977
|
+
source: str | None,
|
|
978
|
+
) -> tuple[Any, Any, str | None]:
|
|
979
|
+
"""Apply the ``mail.ssl.*`` cascade level to a partial resolution."""
|
|
980
|
+
if verify is not None and ca_bundle is not None:
|
|
981
|
+
return verify, ca_bundle, source
|
|
982
|
+
mail_ssl = _load_mail_ssl_section()
|
|
983
|
+
if mail_ssl is None:
|
|
984
|
+
return verify, ca_bundle, source
|
|
985
|
+
if verify is None:
|
|
986
|
+
candidate = mail_ssl.get("verify")
|
|
987
|
+
if candidate is not None:
|
|
988
|
+
verify = candidate
|
|
989
|
+
source = "mail.ssl"
|
|
990
|
+
if ca_bundle is None:
|
|
991
|
+
ca_bundle = mail_ssl.get("ca_bundle")
|
|
992
|
+
return verify, ca_bundle, source
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _cascade_root_ssl_level(
|
|
996
|
+
verify: Any,
|
|
997
|
+
ca_bundle: Any,
|
|
998
|
+
source: str | None,
|
|
999
|
+
) -> tuple[Any, Any, str | None]:
|
|
1000
|
+
"""Apply the root ``ssl.*`` cascade level to a partial resolution."""
|
|
1001
|
+
if verify is not None and ca_bundle is not None:
|
|
1002
|
+
return verify, ca_bundle, source
|
|
1003
|
+
root_ssl = _load_root_ssl_config()
|
|
1004
|
+
if root_ssl is None:
|
|
1005
|
+
return verify, ca_bundle, source
|
|
1006
|
+
if verify is None:
|
|
1007
|
+
verify = root_ssl.verify
|
|
1008
|
+
source = "ssl (root)"
|
|
1009
|
+
if ca_bundle is None:
|
|
1010
|
+
ca_bundle = root_ssl.ca_bundle
|
|
1011
|
+
return verify, ca_bundle, source
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _validate_mail_ssl_types(verify: Any, ca_bundle: Any) -> tuple[bool, str | None]:
|
|
1015
|
+
"""Reject non-bool verify and non-str ca_bundle before SSLContext creation."""
|
|
1016
|
+
if not isinstance(verify, bool):
|
|
1017
|
+
msg = f"mail SSL verify must be bool, got {type(verify).__name__}: {verify!r}"
|
|
1018
|
+
raise TypeError(msg)
|
|
1019
|
+
if ca_bundle is not None and not isinstance(ca_bundle, str):
|
|
1020
|
+
msg = f"mail SSL ca_bundle must be str or null, got {type(ca_bundle).__name__}"
|
|
1021
|
+
raise TypeError(msg)
|
|
1022
|
+
return verify, ca_bundle
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _warn_if_mail_verify_disabled(verify: bool, source: str | None) -> None:
|
|
1026
|
+
"""Emit a single source-tagged WARNING when verify resolved to ``False``."""
|
|
1027
|
+
if verify is False:
|
|
1028
|
+
log.warning(
|
|
1029
|
+
"[SECURITY] SSL certificate verification disabled for mail transport "
|
|
1030
|
+
"(source: %s). This exposes the SMTP session to MITM attacks. Use only "
|
|
1031
|
+
"in trusted environments, or provide ssl_ca_bundle to validate against a private CA.",
|
|
1032
|
+
source,
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _resolve_mail_ssl_config(preset_cfg: Any) -> tuple[bool, str | None]:
|
|
1037
|
+
"""Resolve SSL ``(verify, ca_bundle)`` via a 4-level cascade.
|
|
1038
|
+
|
|
1039
|
+
Priority (highest to lowest):
|
|
1040
|
+
|
|
1041
|
+
1. Preset level: ``preset_cfg.ssl_verify`` / ``preset_cfg.ssl_ca_bundle``
|
|
1042
|
+
2. Mail level: ``config.mail.ssl.verify`` / ``config.mail.ssl.ca_bundle``
|
|
1043
|
+
3. Root level: ``config.ssl.verify`` / ``config.ssl.ca_bundle``
|
|
1044
|
+
(read via :func:`kstlib.ssl.get_ssl_config`)
|
|
1045
|
+
4. Python defaults: ``True`` / ``None``
|
|
1046
|
+
|
|
1047
|
+
The two keys cascade **independently**: ``ssl_verify`` can come from the
|
|
1048
|
+
preset while ``ssl_ca_bundle`` comes from ``mail.ssl``, for example.
|
|
1049
|
+
|
|
1050
|
+
When the resolved ``ssl_verify`` is ``False``, a single WARNING is logged
|
|
1051
|
+
naming the source level (``"preset"``, ``"mail.ssl"``, ``"ssl (root)"``
|
|
1052
|
+
or ``"default"``) to help operators debug misconfigured relays.
|
|
1053
|
+
|
|
1054
|
+
Args:
|
|
1055
|
+
preset_cfg: Preset configuration section (Box or dict) for the
|
|
1056
|
+
SMTP transport being built.
|
|
1057
|
+
|
|
1058
|
+
Returns:
|
|
1059
|
+
Tuple ``(ssl_verify, ssl_ca_bundle)``. ``ssl_verify`` is always a
|
|
1060
|
+
bool. ``ssl_ca_bundle`` is either the raw path string (path
|
|
1061
|
+
validation is performed downstream in
|
|
1062
|
+
:func:`_build_smtp_ssl_context`) or ``None``.
|
|
1063
|
+
|
|
1064
|
+
Raises:
|
|
1065
|
+
TypeError: If a resolved ``ssl_verify`` value is not a bool, or if
|
|
1066
|
+
``ssl_ca_bundle`` is not a string. YAML may emit ``"yes"`` or
|
|
1067
|
+
other non-bool scalars; we reject rather than silently coerce.
|
|
1068
|
+
|
|
1069
|
+
"""
|
|
1070
|
+
verify: Any = preset_cfg.get("ssl_verify") if hasattr(preset_cfg, "get") else None
|
|
1071
|
+
ca_bundle: Any = preset_cfg.get("ssl_ca_bundle") if hasattr(preset_cfg, "get") else None
|
|
1072
|
+
source: str | None = "preset" if verify is not None else None
|
|
1073
|
+
|
|
1074
|
+
verify, ca_bundle, source = _cascade_mail_ssl_level(verify, ca_bundle, source)
|
|
1075
|
+
verify, ca_bundle, source = _cascade_root_ssl_level(verify, ca_bundle, source)
|
|
1076
|
+
|
|
1077
|
+
if verify is None:
|
|
1078
|
+
verify = True
|
|
1079
|
+
source = "default"
|
|
1080
|
+
|
|
1081
|
+
resolved_verify, resolved_ca_bundle = _validate_mail_ssl_types(verify, ca_bundle)
|
|
1082
|
+
_warn_if_mail_verify_disabled(resolved_verify, source)
|
|
1083
|
+
return resolved_verify, resolved_ca_bundle
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _build_smtp_ssl_context(ssl_verify: bool, ssl_ca_bundle: str | None) -> ssl.SSLContext:
|
|
1087
|
+
"""Build a stdlib :class:`ssl.SSLContext` from resolved cascade values.
|
|
1088
|
+
|
|
1089
|
+
Precedence rule: ``ssl_ca_bundle`` takes priority over ``ssl_verify``.
|
|
1090
|
+
Providing a CA bundle expresses intent to verify (against that bundle),
|
|
1091
|
+
so the returned context keeps ``verify_mode=CERT_REQUIRED`` and
|
|
1092
|
+
``check_hostname=True``. Only when no CA bundle is set and
|
|
1093
|
+
``ssl_verify`` is ``False`` does the context fall back to ``CERT_NONE``.
|
|
1094
|
+
|
|
1095
|
+
This function is **pure**: it does not read configuration nor emit log
|
|
1096
|
+
warnings. The cascade and the security warning live in
|
|
1097
|
+
:func:`_resolve_mail_ssl_config`.
|
|
1098
|
+
|
|
1099
|
+
Args:
|
|
1100
|
+
ssl_verify: Resolved verify flag (bool).
|
|
1101
|
+
ssl_ca_bundle: Resolved CA bundle path, or ``None``.
|
|
1102
|
+
|
|
1103
|
+
Returns:
|
|
1104
|
+
Configured :class:`ssl.SSLContext` suitable for
|
|
1105
|
+
:meth:`smtplib.SMTP.starttls` or :class:`smtplib.SMTP_SSL`.
|
|
1106
|
+
|
|
1107
|
+
Raises:
|
|
1108
|
+
MailConfigurationError: If ``ssl_ca_bundle`` is set but invalid
|
|
1109
|
+
(path traversal, null byte, missing file, non-PEM content,
|
|
1110
|
+
unreadable, etc.). Validation is delegated to
|
|
1111
|
+
:func:`kstlib.ssl.validate_ca_bundle_path`.
|
|
1112
|
+
|
|
1113
|
+
"""
|
|
1114
|
+
if ssl_ca_bundle is not None:
|
|
1115
|
+
try:
|
|
1116
|
+
validated_path = validate_ca_bundle_path(ssl_ca_bundle)
|
|
1117
|
+
except (TypeError, ValueError) as exc:
|
|
1118
|
+
msg = f"Invalid ssl_ca_bundle for mail transport: {exc}"
|
|
1119
|
+
raise MailConfigurationError(msg) from exc
|
|
1120
|
+
return ssl.create_default_context(cafile=validated_path)
|
|
1121
|
+
|
|
1122
|
+
if not ssl_verify:
|
|
1123
|
+
ctx = ssl.create_default_context()
|
|
1124
|
+
ctx.check_hostname = False
|
|
1125
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
1126
|
+
return ctx
|
|
1127
|
+
|
|
1128
|
+
return ssl.create_default_context()
|
|
1129
|
+
|
|
1130
|
+
|
|
786
1131
|
def _build_smtp_transport(cfg: Any) -> SMTPTransport:
|
|
787
1132
|
"""Build ``SMTPTransport`` from a preset config section.
|
|
788
1133
|
|
|
1134
|
+
SSL configuration follows a 4-level cascade (preset > ``mail.ssl`` >
|
|
1135
|
+
root ``ssl`` > Python default). See :func:`_resolve_mail_ssl_config`
|
|
1136
|
+
for the detailed priority rules and :func:`_build_smtp_ssl_context`
|
|
1137
|
+
for the context construction.
|
|
1138
|
+
|
|
789
1139
|
Args:
|
|
790
1140
|
cfg: Box/dict with smtp preset fields (host, port, login, password,
|
|
791
|
-
starttls, ssl, timeout).
|
|
1141
|
+
starttls, ssl, timeout, ssl_verify, ssl_ca_bundle).
|
|
792
1142
|
|
|
793
1143
|
Returns:
|
|
794
1144
|
Configured ``SMTPTransport`` instance.
|
|
795
1145
|
|
|
796
1146
|
Raises:
|
|
797
|
-
MailConfigurationError: If the ``host`` field is missing
|
|
1147
|
+
MailConfigurationError: If the ``host`` field is missing or if
|
|
1148
|
+
``ssl_ca_bundle`` is invalid.
|
|
1149
|
+
TypeError: If ``ssl_verify`` is not a bool (after cascade).
|
|
798
1150
|
|
|
799
1151
|
"""
|
|
800
1152
|
from kstlib.mail.transports.smtp import SMTPCredentials, SMTPSecurity, SMTPTransport
|
|
@@ -810,9 +1162,13 @@ def _build_smtp_transport(cfg: Any) -> SMTPTransport:
|
|
|
810
1162
|
password = cfg.get("password")
|
|
811
1163
|
credentials = SMTPCredentials(username=login, password=password) if login else None
|
|
812
1164
|
|
|
1165
|
+
ssl_verify, ssl_ca_bundle = _resolve_mail_ssl_config(cfg)
|
|
1166
|
+
ssl_context = _build_smtp_ssl_context(ssl_verify, ssl_ca_bundle)
|
|
1167
|
+
|
|
813
1168
|
security = SMTPSecurity(
|
|
814
1169
|
use_ssl=bool(cfg.get("ssl", False)),
|
|
815
1170
|
use_starttls=bool(cfg.get("starttls", True)),
|
|
1171
|
+
ssl_context=ssl_context,
|
|
816
1172
|
)
|
|
817
1173
|
|
|
818
1174
|
return SMTPTransport(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kstlib
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Config-driven helpers for Python projects (dynamic config, secure secrets, preset logging, and more…)
|
|
5
5
|
Author-email: Michel TRUONG <michel.truong@gmail.com>
|
|
6
6
|
Maintainer-email: Michel TRUONG <michel.truong@gmail.com>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|