kstlib 2.6.0__tar.gz → 2.7.1__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.6.0/src/kstlib.egg-info → kstlib-2.7.1}/PKG-INFO +2 -2
- {kstlib-2.6.0 → kstlib-2.7.1}/pyproject.toml +1 -1
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/__init__.py +6 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/callback.py +1 -1
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/check.py +1 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/errors.py +9 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/providers/oauth2.py +1 -1
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/common.py +1 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/list_sessions.py +1 -1
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/pool.py +2 -2
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/kstlib.conf.yml +38 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/limits.py +14 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/logging/manager.py +4 -2
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/__init__.py +10 -1
- kstlib-2.7.1/src/kstlib/mail/_helpers.py +81 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/builder.py +48 -38
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/exceptions.py +14 -0
- kstlib-2.7.1/src/kstlib/mail/throttle.py +520 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/ses.py +1 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/meta.py +1 -1
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/delivery.py +1 -1
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/service.py +2 -2
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/_base.py +1 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/spinner.py +1 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/websocket/manager.py +1 -1
- {kstlib-2.6.0 → kstlib-2.7.1/src/kstlib.egg-info}/PKG-INFO +2 -2
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib.egg-info/SOURCES.txt +2 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib.egg-info/requires.txt +1 -1
- {kstlib-2.6.0 → kstlib-2.7.1}/LICENSE.md +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/MANIFEST.in +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/README.md +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/setup.cfg +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/__main__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/_shared/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/_shared/jinja.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/_shared/redaction.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/channels/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/channels/base.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/channels/email.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/channels/slack.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/manager.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/models.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/alerts/throttle.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/config.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/models.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/providers/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/providers/base.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/providers/oidc.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/session.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/auth/token.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cache/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cache/decorator.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cache/strategies.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/app.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/check.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/common.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/login.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/logout.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/providers.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/status.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/token.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/auth/whoami.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/config.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/attach.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/logs.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/start.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/status.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/ops/stop.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/rapi/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/rapi/call.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/rapi/list.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/rapi/show.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/common.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/decrypt.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/doctor.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/encrypt.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/commands/secrets/shred.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/cli/common.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/export.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/loader.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/config/sops.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/aiosqlcipher.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/cipher.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/database.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/db/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/helpers/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/helpers/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/helpers/time_trigger.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/logging/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/collector.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/filesystem.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transport.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/gmail.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/resend.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/mail/transports/smtp.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/metrics/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/metrics/decorators.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/metrics/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/_styles.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/cell.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/config.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/image.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/kv.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/list.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/metric.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/monitoring.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/renderer.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/table.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/monitoring/types.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/base.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/container.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/manager.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/models.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/tmux.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ops/validators.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/base.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/models.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/runner.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/_helpers.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/callable.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/python.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/steps/shell.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/pipeline/validators.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/py.typed +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/client.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/config.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/credentials.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/rapi/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/circuit_breaker.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/heartbeat.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/rate_limiter.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/shutdown.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/resilience/watchdog.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/models.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/base.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/environment.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/keyring.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/kms.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/kwargs.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/providers/sops.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/resolver.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secrets/sensitive.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secure/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secure/fs.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/secure/permissions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ssl.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/chain.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/config.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/primitives.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/transform/validators.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/panels.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/ui/tables.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/dict.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/formatting.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/http_trace.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/lazy.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/secure_delete.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/serialization.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/text.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/utils/validators.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/websocket/__init__.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/websocket/exceptions.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib/websocket/models.py +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib.egg-info/dependency_links.txt +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/src/kstlib.egg-info/entry_points.txt +0 -0
- {kstlib-2.6.0 → kstlib-2.7.1}/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.7.1
|
|
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>
|
|
@@ -46,7 +46,7 @@ Requires-Dist: authlib<2,>=1.6.11
|
|
|
46
46
|
Requires-Dist: pendulum<4,>=3.0
|
|
47
47
|
Requires-Dist: cryptography>=46.0.7
|
|
48
48
|
Requires-Dist: requests>=2.33.0
|
|
49
|
-
Requires-Dist: urllib3>=2.
|
|
49
|
+
Requires-Dist: urllib3>=2.7.0
|
|
50
50
|
Provides-Extra: dev
|
|
51
51
|
Requires-Dist: pytest<10,>=9.0.3; extra == "dev"
|
|
52
52
|
Requires-Dist: pytest-cov<8,>=7.0; extra == "dev"
|
|
@@ -64,7 +64,7 @@ dependencies = [
|
|
|
64
64
|
# --- Security: transitive dep lower bounds (CVE) ---
|
|
65
65
|
"cryptography>=46.0.7", # CVE-2026-26007 + CVE-2026-34073 + CVE-2026-39892
|
|
66
66
|
"requests>=2.33.0", # CVE-2024-47081 + CVE-2026-25645 (via httpx)
|
|
67
|
-
"urllib3>=2.
|
|
67
|
+
"urllib3>=2.7.0", # CVE-2025-50182/50181 + CVE-2025-66418/66471 + CVE-2026-21441 + CVE-2026-44431 + CVE-2026-44432 (via requests)
|
|
68
68
|
]
|
|
69
69
|
classifiers = [
|
|
70
70
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -39,6 +39,11 @@ from __future__ import annotations
|
|
|
39
39
|
import importlib
|
|
40
40
|
from typing import TYPE_CHECKING, Any
|
|
41
41
|
|
|
42
|
+
# Eager-loaded version string (PEP 396 / standard ergonomy).
|
|
43
|
+
# Imported eagerly so `kstlib.__version__` works without triggering the
|
|
44
|
+
# lazy-loader. The cost is negligible (one trivial module load).
|
|
45
|
+
from kstlib.meta import __version__
|
|
46
|
+
|
|
42
47
|
# Public API exports (sorted alphabetically)
|
|
43
48
|
__all__ = [
|
|
44
49
|
"ConfigCircularIncludeError",
|
|
@@ -52,6 +57,7 @@ __all__ = [
|
|
|
52
57
|
"MonitoringError",
|
|
53
58
|
"PanelManager",
|
|
54
59
|
"PanelRenderingError",
|
|
60
|
+
"__version__",
|
|
55
61
|
"alerts",
|
|
56
62
|
"app",
|
|
57
63
|
"auth",
|
|
@@ -327,7 +327,7 @@ class CallbackServer: # pylint: disable=too-many-instance-attributes
|
|
|
327
327
|
if self._server is None:
|
|
328
328
|
return
|
|
329
329
|
while not self._stop_flag and self._server:
|
|
330
|
-
try:
|
|
330
|
+
try: # reason: per-iteration tolerance for HTTP request handler loop
|
|
331
331
|
self._server.handle_request()
|
|
332
332
|
except Exception: # pylint: disable=broad-exception-caught
|
|
333
333
|
if not self._stop_flag:
|
|
@@ -171,6 +171,7 @@ class TokenChecker:
|
|
|
171
171
|
expected_issuer: str | None = None,
|
|
172
172
|
expected_audience: str | None = None,
|
|
173
173
|
) -> None:
|
|
174
|
+
"""Initialize the token checker with an HTTP client and optional expected claims."""
|
|
174
175
|
self._http = http_client
|
|
175
176
|
self._expected_issuer = expected_issuer
|
|
176
177
|
self._expected_audience = expected_audience
|
|
@@ -11,6 +11,7 @@ class AuthError(KstlibError):
|
|
|
11
11
|
"""Base exception for all authentication errors."""
|
|
12
12
|
|
|
13
13
|
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
14
|
+
"""Initialize the auth error with a message and optional structured details."""
|
|
14
15
|
super().__init__(message)
|
|
15
16
|
self.message = message
|
|
16
17
|
self.details = details or {}
|
|
@@ -24,6 +25,7 @@ class ProviderNotFoundError(AuthError):
|
|
|
24
25
|
"""Raised when a named provider is not configured."""
|
|
25
26
|
|
|
26
27
|
def __init__(self, provider_name: str) -> None:
|
|
28
|
+
"""Initialize with the name of the missing provider."""
|
|
27
29
|
super().__init__(f"Provider '{provider_name}' not found in configuration")
|
|
28
30
|
self.provider_name = provider_name
|
|
29
31
|
|
|
@@ -32,6 +34,7 @@ class DiscoveryError(AuthError):
|
|
|
32
34
|
"""Raised when OIDC discovery fails."""
|
|
33
35
|
|
|
34
36
|
def __init__(self, issuer: str, reason: str) -> None:
|
|
37
|
+
"""Initialize with the failing issuer URL and the reason for the failure."""
|
|
35
38
|
super().__init__(f"Discovery failed for '{issuer}': {reason}")
|
|
36
39
|
self.issuer = issuer
|
|
37
40
|
self.reason = reason
|
|
@@ -49,6 +52,7 @@ class TokenRefreshError(TokenError):
|
|
|
49
52
|
"""Raised when token refresh fails."""
|
|
50
53
|
|
|
51
54
|
def __init__(self, reason: str, *, retryable: bool = False) -> None:
|
|
55
|
+
"""Initialize with the reason for the refresh failure and a retryable flag."""
|
|
52
56
|
super().__init__(f"Token refresh failed: {reason}")
|
|
53
57
|
self.reason = reason
|
|
54
58
|
self.retryable = retryable
|
|
@@ -58,6 +62,7 @@ class TokenExchangeError(TokenError):
|
|
|
58
62
|
"""Raised when authorization code exchange fails."""
|
|
59
63
|
|
|
60
64
|
def __init__(self, reason: str, *, error_code: str | None = None) -> None:
|
|
65
|
+
"""Initialize with the reason for the exchange failure and an optional OAuth error code."""
|
|
61
66
|
super().__init__(f"Token exchange failed: {reason}")
|
|
62
67
|
self.reason = reason
|
|
63
68
|
self.error_code = error_code
|
|
@@ -67,6 +72,7 @@ class TokenValidationError(TokenError):
|
|
|
67
72
|
"""Raised when JWT validation fails (signature, claims, expiry)."""
|
|
68
73
|
|
|
69
74
|
def __init__(self, reason: str, *, claim: str | None = None) -> None:
|
|
75
|
+
"""Initialize with the reason for the validation failure and the offending claim name."""
|
|
70
76
|
super().__init__(f"Token validation failed: {reason}")
|
|
71
77
|
self.reason = reason
|
|
72
78
|
self.claim = claim
|
|
@@ -86,6 +92,7 @@ class AuthorizationError(AuthError):
|
|
|
86
92
|
error_code: str | None = None,
|
|
87
93
|
error_description: str | None = None,
|
|
88
94
|
) -> None:
|
|
95
|
+
"""Initialize with the reason for the failure plus optional OAuth error code and description."""
|
|
89
96
|
super().__init__(f"Authorization failed: {reason}")
|
|
90
97
|
self.reason = reason
|
|
91
98
|
self.error_code = error_code
|
|
@@ -96,6 +103,7 @@ class CallbackServerError(AuthError):
|
|
|
96
103
|
"""Raised when the local callback server fails to start or receive callback."""
|
|
97
104
|
|
|
98
105
|
def __init__(self, reason: str, *, port: int | None = None) -> None:
|
|
106
|
+
"""Initialize with the reason for the callback server failure and the port that was in use."""
|
|
99
107
|
super().__init__(f"Callback server error: {reason}")
|
|
100
108
|
self.reason = reason
|
|
101
109
|
self.port = port
|
|
@@ -105,6 +113,7 @@ class PreflightError(AuthError):
|
|
|
105
113
|
"""Raised when preflight validation fails."""
|
|
106
114
|
|
|
107
115
|
def __init__(self, step: str, reason: str) -> None:
|
|
116
|
+
"""Initialize with the failing preflight step name and the reason for the failure."""
|
|
108
117
|
super().__init__(f"Preflight failed at '{step}': {reason}")
|
|
109
118
|
self.step = step
|
|
110
119
|
self.reason = reason
|
|
@@ -453,7 +453,7 @@ class OAuth2Provider(AbstractAuthProvider):
|
|
|
453
453
|
|
|
454
454
|
success = False
|
|
455
455
|
for token_type_hint, token_value in tokens_to_revoke:
|
|
456
|
-
try:
|
|
456
|
+
try: # reason: per-token revocation; partial success allowed (RFC 7009)
|
|
457
457
|
data: dict[str, Any] = {
|
|
458
458
|
"token": token_value,
|
|
459
459
|
"token_type_hint": token_type_hint,
|
|
@@ -202,6 +202,7 @@ def _scan_tmux_sockets(name: str) -> tuple[str | None, str | None]:
|
|
|
202
202
|
Tuple of (backend, socket_name). Both are ``None`` if no match is
|
|
203
203
|
found. When a socket holds the session, ``("tmux", <sock>)`` is
|
|
204
204
|
returned.
|
|
205
|
+
|
|
205
206
|
"""
|
|
206
207
|
for sock in discover_tmux_sockets():
|
|
207
208
|
try:
|
|
@@ -163,7 +163,7 @@ def _collect_sessions(backend: str | None) -> list[SessionStatus]:
|
|
|
163
163
|
|
|
164
164
|
# Discover sessions on custom tmux sockets
|
|
165
165
|
for socket_name in discover_tmux_sockets():
|
|
166
|
-
try:
|
|
166
|
+
try: # reason: per-socket discovery; missing tmux socket is non-fatal
|
|
167
167
|
tmux = TmuxRunner(socket_name=socket_name)
|
|
168
168
|
sessions.extend(tmux.list_sessions())
|
|
169
169
|
except BackendNotFoundError:
|
|
@@ -281,7 +281,7 @@ class ConnectionPool:
|
|
|
281
281
|
async with self._lock:
|
|
282
282
|
# Close all connections
|
|
283
283
|
for conn in self._connections:
|
|
284
|
-
try:
|
|
284
|
+
try: # reason: per-connection best-effort close on pool teardown
|
|
285
285
|
await conn.execute("PRAGMA optimize")
|
|
286
286
|
await conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
287
287
|
await conn.close()
|
|
@@ -292,7 +292,7 @@ class ConnectionPool:
|
|
|
292
292
|
|
|
293
293
|
# Empty the queue
|
|
294
294
|
while not self._pool.empty():
|
|
295
|
-
try:
|
|
295
|
+
try: # reason: race-safe queue drain (QueueEmpty IS the loop terminator)
|
|
296
296
|
self._pool.get_nowait()
|
|
297
297
|
except asyncio.QueueEmpty:
|
|
298
298
|
break
|
|
@@ -424,6 +424,39 @@ mail:
|
|
|
424
424
|
# Leave null to force callers to pass transport= or preset= explicitly.
|
|
425
425
|
default: null
|
|
426
426
|
|
|
427
|
+
# Anti-spam throttle (kill switch). Enforced before the transport on
|
|
428
|
+
# every MailBuilder.send(), including indirect calls via @mail.notify.
|
|
429
|
+
#
|
|
430
|
+
# Cascade (highest priority first):
|
|
431
|
+
# 1. mail.presets.<name>.throttle.<key> (preset-level override)
|
|
432
|
+
# 2. mail.throttle.<key> (this section, mail-wide default)
|
|
433
|
+
# 3. Code defaults (rate=20, per=60.0, on_exceed=raise)
|
|
434
|
+
#
|
|
435
|
+
# Each key cascades independently: a preset can override only ``rate``
|
|
436
|
+
# while keeping the mail-wide ``per`` and ``on_exceed``.
|
|
437
|
+
#
|
|
438
|
+
# Modes:
|
|
439
|
+
# - raise (default): emits WARNING [SECURITY] then raises
|
|
440
|
+
# MailThrottledError. The caller decides how to back off.
|
|
441
|
+
# - warn: emits WARNING [SECURITY] then drops the mail silently
|
|
442
|
+
# (returns the built message without sending).
|
|
443
|
+
# - drop (silent) is INTENTIONALLY REJECTED at init: a security
|
|
444
|
+
# event must never be silent (kstlib logging convention).
|
|
445
|
+
#
|
|
446
|
+
# Singleton: a single MailThrottle is shared across all builders that
|
|
447
|
+
# use the same preset, including snapshots taken by the @notify
|
|
448
|
+
# decorator. This prevents bypass via creating many builder instances.
|
|
449
|
+
#
|
|
450
|
+
# Hard limits enforced in code (see kstlib.limits):
|
|
451
|
+
# - rate: 1 to 1000 mails per period
|
|
452
|
+
# - per: 1.0 to 86400.0 seconds (1 day)
|
|
453
|
+
# - on_exceed: "raise" or "warn"
|
|
454
|
+
throttle:
|
|
455
|
+
enabled: true
|
|
456
|
+
rate: 20
|
|
457
|
+
per: 60.0
|
|
458
|
+
on_exceed: raise
|
|
459
|
+
|
|
427
460
|
# SSL/TLS configuration for mail transports.
|
|
428
461
|
#
|
|
429
462
|
# Values here override the root ``ssl:`` section (bottom of this file)
|
|
@@ -461,6 +494,11 @@ mail:
|
|
|
461
494
|
# defaults:
|
|
462
495
|
# sender: "Service Notifications <notify@corp.local>"
|
|
463
496
|
# reply_to: "Service Notifications <notify@corp.local>"
|
|
497
|
+
# # Optional throttle override (highest priority, see mail.throttle above):
|
|
498
|
+
# throttle:
|
|
499
|
+
# rate: 5 # corporate is more restrictive than mail-wide default
|
|
500
|
+
# per: 60.0
|
|
501
|
+
# on_exceed: warn # operational critical, prefer drop+log over raise
|
|
464
502
|
#
|
|
465
503
|
# transactional:
|
|
466
504
|
# transport: resend
|
|
@@ -16,6 +16,8 @@ Example:
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
from typing import Literal as _Literal
|
|
20
|
+
|
|
19
21
|
# =============================================================================
|
|
20
22
|
# Pre-import constants (hard limits + defaults)
|
|
21
23
|
#
|
|
@@ -126,6 +128,11 @@ HARD_MAX_THROTTLE_RATE = 1000
|
|
|
126
128
|
HARD_MIN_THROTTLE_PER = 1.0
|
|
127
129
|
HARD_MAX_THROTTLE_PER = 86400.0 # 1 day
|
|
128
130
|
|
|
131
|
+
#: Mail throttle registry size cap. Beyond this number of distinct preset
|
|
132
|
+
#: names, get_or_create_throttle refuses new entries to bound memory under
|
|
133
|
+
#: multi-tenant misuse (e.g. dynamic UUID-suffixed preset names).
|
|
134
|
+
HARD_MAX_THROTTLE_REGISTRY_SIZE = 100
|
|
135
|
+
|
|
129
136
|
#: Alert channel timeout bounds (seconds) - protects against too short or hanging requests.
|
|
130
137
|
HARD_MIN_CHANNEL_TIMEOUT = 1.0
|
|
131
138
|
HARD_MAX_CHANNEL_TIMEOUT = 120.0
|
|
@@ -246,6 +253,13 @@ DEFAULT_THROTTLE_BURST = 5 # initial capacity
|
|
|
246
253
|
DEFAULT_CHANNEL_TIMEOUT = 30.0 # seconds
|
|
247
254
|
DEFAULT_CHANNEL_RETRIES = 2
|
|
248
255
|
|
|
256
|
+
#: Mail throttle defaults (anti-spam kill switch).
|
|
257
|
+
#: Hard limits are shared with alerts (HARD_MIN/MAX_THROTTLE_RATE / _PER).
|
|
258
|
+
#: Mode 'drop' is intentionally not supported (security event must never be silent).
|
|
259
|
+
DEFAULT_MAIL_THROTTLE_RATE = 20 # mails per period
|
|
260
|
+
DEFAULT_MAIL_THROTTLE_PER = 60.0 # seconds
|
|
261
|
+
DEFAULT_MAIL_THROTTLE_ON_EXCEED: _Literal["raise", "warn"] = "raise"
|
|
262
|
+
|
|
249
263
|
DEFAULT_WS_PING_INTERVAL = 20.0 # seconds
|
|
250
264
|
DEFAULT_WS_PING_TIMEOUT = 10.0 # seconds
|
|
251
265
|
DEFAULT_WS_CONNECTION_TIMEOUT = 30.0 # seconds
|
|
@@ -74,7 +74,8 @@ FORBIDDEN_PATH_COMPONENTS: frozenset[str] = frozenset({"..", "~"})
|
|
|
74
74
|
ALLOWED_LOG_EXTENSIONS: frozenset[str] = frozenset({".log", ".txt", ".json", ""})
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
#
|
|
77
|
+
# Native async file I/O via aiofiles is a potential future enhancement;
|
|
78
|
+
# current implementation uses thread-pool wrappers (see methods below).
|
|
78
79
|
# try:
|
|
79
80
|
# import aiofiles
|
|
80
81
|
# import aiofiles.os
|
|
@@ -882,7 +883,8 @@ class LogManager(logging.Logger):
|
|
|
882
883
|
"""Return whether native async logs are available."""
|
|
883
884
|
return HAS_ASYNC
|
|
884
885
|
|
|
885
|
-
# Async logging methods (
|
|
886
|
+
# Async logging methods (thread-pool wrappers; native aiofiles via
|
|
887
|
+
# optional dep is a potential future optimization).
|
|
886
888
|
|
|
887
889
|
async def atrace(self, msg: str, **context: Any) -> None:
|
|
888
890
|
"""Async trace wrapper executed via thread pool."""
|
|
@@ -26,8 +26,15 @@ Examples:
|
|
|
26
26
|
|
|
27
27
|
from kstlib.mail.builder import MailBuilder, NotifyResult
|
|
28
28
|
from kstlib.mail.collector import NotifyCollector
|
|
29
|
-
from kstlib.mail.exceptions import
|
|
29
|
+
from kstlib.mail.exceptions import (
|
|
30
|
+
MailConfigurationError,
|
|
31
|
+
MailError,
|
|
32
|
+
MailThrottledError,
|
|
33
|
+
MailTransportError,
|
|
34
|
+
MailValidationError,
|
|
35
|
+
)
|
|
30
36
|
from kstlib.mail.filesystem import MailFilesystemGuards
|
|
37
|
+
from kstlib.mail.throttle import MailThrottle
|
|
31
38
|
from kstlib.mail.transport import AsyncMailTransport, AsyncTransportWrapper, MailTransport
|
|
32
39
|
|
|
33
40
|
__all__ = [
|
|
@@ -37,6 +44,8 @@ __all__ = [
|
|
|
37
44
|
"MailConfigurationError",
|
|
38
45
|
"MailError",
|
|
39
46
|
"MailFilesystemGuards",
|
|
47
|
+
"MailThrottle",
|
|
48
|
+
"MailThrottledError",
|
|
40
49
|
"MailTransport",
|
|
41
50
|
"MailTransportError",
|
|
42
51
|
"MailValidationError",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Internal helpers for the mail subpackage.
|
|
2
|
+
|
|
3
|
+
Single source of truth for the ``mail`` configuration section access
|
|
4
|
+
shared by :mod:`kstlib.mail.builder` (cascade transport / SSL / preset
|
|
5
|
+
resolution) and :mod:`kstlib.mail.throttle` (anti-spam kill switch
|
|
6
|
+
init).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from kstlib.mail.exceptions import MailConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_mail_section(*, silent: bool = False) -> Any:
|
|
17
|
+
"""Read the ``mail`` section from the kstlib configuration.
|
|
18
|
+
|
|
19
|
+
Two distinct contracts share this implementation:
|
|
20
|
+
|
|
21
|
+
- ``silent=False`` (used by :mod:`kstlib.mail.builder`):
|
|
22
|
+
preserves the legacy single-exception-type contract. Every loader
|
|
23
|
+
failure (``ImportError``, ``ConfigNotLoadedError``, ``YAMLError``,
|
|
24
|
+
``OSError``, ``RuntimeError``, ...) is wrapped in
|
|
25
|
+
:class:`~kstlib.mail.MailConfigurationError`. Existing builder
|
|
26
|
+
callers and tests rely on this wrap.
|
|
27
|
+
|
|
28
|
+
- ``silent=True`` (used by :mod:`kstlib.mail.throttle`):
|
|
29
|
+
narrow catch. Only "config absent / not loaded" errors
|
|
30
|
+
(``ImportError``, ``ConfigNotLoadedError``,
|
|
31
|
+
``MailConfigurationError``) are suppressed and return ``None``.
|
|
32
|
+
Real corruption (``YAMLError``, ``OSError``, ``RuntimeError``,
|
|
33
|
+
...) propagates so the throttle init crashes explicitly rather
|
|
34
|
+
than silently disabling.
|
|
35
|
+
|
|
36
|
+
The asymmetry is intentional: the throttle is an operational
|
|
37
|
+
kill switch, surfacing corruption is preferable to silently
|
|
38
|
+
weakening the safety net. Full unification (narrow catch on both
|
|
39
|
+
paths) would be a breaking change for the builder API and is
|
|
40
|
+
deferred to a future major release.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
silent: If ``True``, suppress "config not loaded" errors and
|
|
44
|
+
return ``None``. Real corruption errors always propagate.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The mail section as a Box / dict, or ``None`` if the section
|
|
48
|
+
is missing or (``silent=True``) the config is not loaded.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
MailConfigurationError: If ``silent=False`` and the config
|
|
52
|
+
cannot be loaded for any reason. The original exception is
|
|
53
|
+
chained via ``__cause__``.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
from kstlib.config import get_config
|
|
58
|
+
from kstlib.config.exceptions import ConfigNotLoadedError
|
|
59
|
+
except ImportError as exc: # pragma: no cover - config is always present
|
|
60
|
+
if silent:
|
|
61
|
+
return None
|
|
62
|
+
raise MailConfigurationError("kstlib.config is not available") from exc
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
cfg: Any = get_config()
|
|
66
|
+
except (ConfigNotLoadedError, MailConfigurationError) as exc:
|
|
67
|
+
if silent:
|
|
68
|
+
return None
|
|
69
|
+
raise MailConfigurationError(f"Failed to load kstlib configuration: not loaded ({exc})") from exc
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
# Real loader corruption (YAMLError, OSError, RuntimeError, ...).
|
|
72
|
+
# silent path: propagate to surface the bug at builder/throttle init.
|
|
73
|
+
# non-silent path: wrap into MailConfigurationError to preserve the
|
|
74
|
+
# legacy single-exception API that existing builder callers rely on.
|
|
75
|
+
if silent:
|
|
76
|
+
raise
|
|
77
|
+
raise MailConfigurationError(f"Failed to load kstlib configuration: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
if not hasattr(cfg, "get"):
|
|
80
|
+
return None
|
|
81
|
+
return cfg.get("mail")
|
|
@@ -20,8 +20,10 @@ from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypeVar, overload
|
|
|
20
20
|
from kstlib._shared.jinja import render_jinja
|
|
21
21
|
from kstlib.limits import MailLimits, get_mail_limits
|
|
22
22
|
from kstlib.logging import get_logger
|
|
23
|
+
from kstlib.mail._helpers import _load_mail_section
|
|
23
24
|
from kstlib.mail.exceptions import MailConfigurationError, MailTransportError, MailValidationError
|
|
24
25
|
from kstlib.mail.filesystem import MailFilesystemGuards
|
|
26
|
+
from kstlib.mail.throttle import MailThrottle, get_or_create_throttle
|
|
25
27
|
from kstlib.ssl import get_ssl_config, validate_ca_bundle_path
|
|
26
28
|
from kstlib.utils import (
|
|
27
29
|
EmailAddress,
|
|
@@ -181,6 +183,7 @@ class MailBuilder:
|
|
|
181
183
|
encoding: str = _DEFAULT_ENCODING,
|
|
182
184
|
filesystem: MailFilesystemGuards | None = None,
|
|
183
185
|
limits: MailLimits | None = None,
|
|
186
|
+
throttle: bool | dict[str, Any] | None = None,
|
|
184
187
|
) -> None:
|
|
185
188
|
"""Initialise the builder with optional transport, preset, charset, and guardrails.
|
|
186
189
|
|
|
@@ -198,11 +201,20 @@ class MailBuilder:
|
|
|
198
201
|
filesystem: Filesystem guardrails for attachments, inline
|
|
199
202
|
resources, and templates.
|
|
200
203
|
limits: Message and attachment limits.
|
|
204
|
+
throttle: Anti-spam kill switch. ``False`` disables the
|
|
205
|
+
throttle on this builder. A ``dict`` (e.g.
|
|
206
|
+
``{"rate": 100, "per": 3600.0, "on_exceed": "warn"}``)
|
|
207
|
+
builds a per-instance custom throttle. ``None`` (default)
|
|
208
|
+
resolves the throttle from configuration via the cascade
|
|
209
|
+
``mail.presets.<name>.throttle`` > ``mail.throttle`` >
|
|
210
|
+
code defaults. See :class:`~kstlib.mail.MailThrottle` for
|
|
211
|
+
the available knobs.
|
|
201
212
|
|
|
202
213
|
Raises:
|
|
203
214
|
MailConfigurationError: If ``preset`` is passed but does not
|
|
204
|
-
resolve to a valid preset in configuration,
|
|
205
|
-
default has a non-string value
|
|
215
|
+
resolve to a valid preset in configuration, if a preset
|
|
216
|
+
default has a non-string value, or if the resolved
|
|
217
|
+
throttle configuration is invalid.
|
|
206
218
|
MailValidationError: If a preset default holds an unparseable
|
|
207
219
|
email address.
|
|
208
220
|
|
|
@@ -220,6 +232,7 @@ class MailBuilder:
|
|
|
220
232
|
self._encoding = encoding
|
|
221
233
|
self._filesystem = filesystem or MailFilesystemGuards.default()
|
|
222
234
|
self._limits = limits or get_mail_limits()
|
|
235
|
+
self._throttle: MailThrottle | None = get_or_create_throttle(resolved_preset_name, throttle)
|
|
223
236
|
self._sender: EmailAddress | None = None
|
|
224
237
|
self._reply_to: EmailAddress | None = None
|
|
225
238
|
self._to: list[EmailAddress] = []
|
|
@@ -380,12 +393,23 @@ class MailBuilder:
|
|
|
380
393
|
def send(self) -> EmailMessage:
|
|
381
394
|
"""Build and send the email using the configured transport.
|
|
382
395
|
|
|
396
|
+
If a :class:`~kstlib.mail.MailThrottle` is attached (default,
|
|
397
|
+
unless disabled via ``throttle=False``), it is consulted before
|
|
398
|
+
the transport. When the bucket is empty, the throttle either
|
|
399
|
+
raises :class:`~kstlib.mail.MailThrottledError` (mode ``raise``)
|
|
400
|
+
or logs a security warning and returns the built message without
|
|
401
|
+
sending (mode ``warn``).
|
|
402
|
+
|
|
383
403
|
Returns:
|
|
384
|
-
The constructed EmailMessage
|
|
404
|
+
The constructed :class:`~email.message.EmailMessage`. In
|
|
405
|
+
``warn`` mode, the returned message may have been dropped
|
|
406
|
+
silently (a ``WARNING [SECURITY]`` log is emitted).
|
|
385
407
|
|
|
386
408
|
Raises:
|
|
387
409
|
MailConfigurationError: If no transport has been configured.
|
|
388
410
|
MailTransportError: If the transport fails to deliver the message.
|
|
411
|
+
MailThrottledError: If the throttle is in ``raise`` mode and
|
|
412
|
+
the bucket is empty.
|
|
389
413
|
|
|
390
414
|
"""
|
|
391
415
|
from kstlib.mail.transport import AsyncMailTransport, MailTransport
|
|
@@ -400,6 +424,8 @@ class MailBuilder:
|
|
|
400
424
|
)
|
|
401
425
|
assert isinstance(self._transport, MailTransport)
|
|
402
426
|
message = self.build()
|
|
427
|
+
if self._throttle is not None and not self._throttle.consume(self._subject):
|
|
428
|
+
return message
|
|
403
429
|
try:
|
|
404
430
|
self._transport.send(message)
|
|
405
431
|
except MailTransportError:
|
|
@@ -415,17 +441,27 @@ class MailBuilder:
|
|
|
415
441
|
def _snapshot(self) -> MailBuilder:
|
|
416
442
|
"""Create an independent copy of this builder for decoration.
|
|
417
443
|
|
|
418
|
-
Returns a copy that shares the transport but has
|
|
419
|
-
message state, so decorated functions don't interfere
|
|
444
|
+
Returns a copy that shares the transport and throttle but has
|
|
445
|
+
independent message state, so decorated functions don't interfere
|
|
446
|
+
with each other. The throttle MUST be shared (not deep-copied)
|
|
447
|
+
so that ``@mail.notify`` on a hot function cannot bypass the
|
|
448
|
+
rate limit by getting its own bucket.
|
|
420
449
|
"""
|
|
421
|
-
# Save transport before deepcopy
|
|
450
|
+
# Save transport and throttle before deepcopy: neither can be
|
|
451
|
+
# safely deep-copied (transport may hold sockets, throttle holds
|
|
452
|
+
# a threading.Lock) and both must be shared by reference so the
|
|
453
|
+
# snapshot enforces the same kill switch as the original builder.
|
|
422
454
|
transport = self._transport
|
|
455
|
+
throttle = self._throttle
|
|
423
456
|
self._transport = None
|
|
457
|
+
self._throttle = None
|
|
424
458
|
try:
|
|
425
459
|
snapshot = copy.deepcopy(self)
|
|
426
460
|
finally:
|
|
427
461
|
self._transport = transport
|
|
462
|
+
self._throttle = throttle
|
|
428
463
|
snapshot._transport = transport
|
|
464
|
+
snapshot._throttle = throttle
|
|
429
465
|
return snapshot
|
|
430
466
|
|
|
431
467
|
@overload
|
|
@@ -935,32 +971,6 @@ def _detect_mime(path: Path) -> tuple[str, str]:
|
|
|
935
971
|
_SUPPORTED_TRANSPORTS = ("smtp", "resend")
|
|
936
972
|
|
|
937
973
|
|
|
938
|
-
def _load_mail_config() -> Any:
|
|
939
|
-
"""Read ``mail`` section from kstlib configuration.
|
|
940
|
-
|
|
941
|
-
Returns:
|
|
942
|
-
The ``mail`` section as a Box/dict, or ``None`` if unavailable.
|
|
943
|
-
|
|
944
|
-
Raises:
|
|
945
|
-
MailConfigurationError: If the config loader cannot be imported or
|
|
946
|
-
raises while reading.
|
|
947
|
-
|
|
948
|
-
"""
|
|
949
|
-
try:
|
|
950
|
-
from kstlib.config import get_config
|
|
951
|
-
except ImportError as exc: # pragma: no cover - config is always present
|
|
952
|
-
raise MailConfigurationError("kstlib.config is not available") from exc
|
|
953
|
-
|
|
954
|
-
try:
|
|
955
|
-
cfg: Any = get_config()
|
|
956
|
-
except Exception as exc:
|
|
957
|
-
raise MailConfigurationError(f"Failed to load kstlib configuration: {exc}") from exc
|
|
958
|
-
|
|
959
|
-
if not hasattr(cfg, "get"):
|
|
960
|
-
return None
|
|
961
|
-
return cfg.get("mail")
|
|
962
|
-
|
|
963
|
-
|
|
964
974
|
def _resolve_default_transport() -> TransportLike | None:
|
|
965
975
|
"""Resolve the transport from ``mail.default`` in configuration.
|
|
966
976
|
|
|
@@ -974,7 +984,7 @@ def _resolve_default_transport() -> TransportLike | None:
|
|
|
974
984
|
|
|
975
985
|
"""
|
|
976
986
|
try:
|
|
977
|
-
mail_cfg =
|
|
987
|
+
mail_cfg = _load_mail_section()
|
|
978
988
|
except MailConfigurationError:
|
|
979
989
|
return None
|
|
980
990
|
|
|
@@ -992,10 +1002,10 @@ def _resolve_default_preset_name() -> str | None:
|
|
|
992
1002
|
|
|
993
1003
|
Swallows any config loading error and returns ``None`` so that
|
|
994
1004
|
``MailBuilder()`` stays usable even when the config file is missing.
|
|
995
|
-
Mirrors the silent-empty contract of :func:`
|
|
1005
|
+
Mirrors the silent-empty contract of :func:`_load_mail_section`.
|
|
996
1006
|
"""
|
|
997
1007
|
try:
|
|
998
|
-
mail_cfg =
|
|
1008
|
+
mail_cfg = _load_mail_section()
|
|
999
1009
|
except MailConfigurationError:
|
|
1000
1010
|
return None
|
|
1001
1011
|
if mail_cfg is None or not hasattr(mail_cfg, "get"):
|
|
@@ -1029,7 +1039,7 @@ def _load_preset_envelope_defaults(preset_name: str) -> dict[str, Any]:
|
|
|
1029
1039
|
|
|
1030
1040
|
"""
|
|
1031
1041
|
try:
|
|
1032
|
-
mail_cfg =
|
|
1042
|
+
mail_cfg = _load_mail_section()
|
|
1033
1043
|
except MailConfigurationError:
|
|
1034
1044
|
return {}
|
|
1035
1045
|
if mail_cfg is None or not hasattr(mail_cfg, "get"):
|
|
@@ -1078,7 +1088,7 @@ def _build_transport_from_preset(preset_name: str) -> TransportLike:
|
|
|
1078
1088
|
field is missing or unsupported, or required fields are absent.
|
|
1079
1089
|
|
|
1080
1090
|
"""
|
|
1081
|
-
mail_cfg =
|
|
1091
|
+
mail_cfg = _load_mail_section()
|
|
1082
1092
|
if mail_cfg is None:
|
|
1083
1093
|
raise MailConfigurationError(
|
|
1084
1094
|
f"Preset '{preset_name}' cannot be resolved: 'mail' section missing from configuration"
|
|
@@ -1113,7 +1123,7 @@ def _load_mail_ssl_section() -> Any | None:
|
|
|
1113
1123
|
config loader cannot be reached.
|
|
1114
1124
|
"""
|
|
1115
1125
|
try:
|
|
1116
|
-
mail_section =
|
|
1126
|
+
mail_section = _load_mail_section()
|
|
1117
1127
|
except MailConfigurationError:
|
|
1118
1128
|
return None
|
|
1119
1129
|
if mail_section is None or not hasattr(mail_section, "get"):
|
|
@@ -21,9 +21,23 @@ class MailConfigurationError(MailError):
|
|
|
21
21
|
"""Raised when the mail builder is missing required configuration."""
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
class MailThrottledError(MailError):
|
|
25
|
+
"""Raised when the mail throttle bucket is empty and on_exceed is 'raise'.
|
|
26
|
+
|
|
27
|
+
Carries the throttle parameters in the message to help the caller
|
|
28
|
+
decide on a backoff strategy. The throttle is config-driven via
|
|
29
|
+
``mail.throttle.*`` and acts as an anti-spam kill switch.
|
|
30
|
+
|
|
31
|
+
See Also:
|
|
32
|
+
:class:`kstlib.mail.MailThrottle`
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
24
37
|
__all__ = [
|
|
25
38
|
"MailConfigurationError",
|
|
26
39
|
"MailError",
|
|
40
|
+
"MailThrottledError",
|
|
27
41
|
"MailTransportError",
|
|
28
42
|
"MailValidationError",
|
|
29
43
|
]
|