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.
Files changed (189) hide show
  1. {kstlib-2.2.1/src/kstlib.egg-info → kstlib-2.3.0}/PKG-INFO +1 -1
  2. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/__init__.py +5 -0
  3. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/__init__.py +2 -0
  4. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/loader.py +42 -0
  5. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/kstlib.conf.yml +23 -1
  6. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/builder.py +362 -6
  7. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/meta.py +1 -1
  8. {kstlib-2.2.1 → kstlib-2.3.0/src/kstlib.egg-info}/PKG-INFO +1 -1
  9. {kstlib-2.2.1 → kstlib-2.3.0}/LICENSE.md +0 -0
  10. {kstlib-2.2.1 → kstlib-2.3.0}/MANIFEST.in +0 -0
  11. {kstlib-2.2.1 → kstlib-2.3.0}/README.md +0 -0
  12. {kstlib-2.2.1 → kstlib-2.3.0}/pyproject.toml +0 -0
  13. {kstlib-2.2.1 → kstlib-2.3.0}/setup.cfg +0 -0
  14. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/__main__.py +0 -0
  15. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/__init__.py +0 -0
  16. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/channels/__init__.py +0 -0
  17. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/channels/base.py +0 -0
  18. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/channels/email.py +0 -0
  19. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/channels/slack.py +0 -0
  20. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/exceptions.py +0 -0
  21. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/manager.py +0 -0
  22. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/models.py +0 -0
  23. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/alerts/throttle.py +0 -0
  24. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/__init__.py +0 -0
  25. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/callback.py +0 -0
  26. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/check.py +0 -0
  27. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/config.py +0 -0
  28. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/errors.py +0 -0
  29. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/models.py +0 -0
  30. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/providers/__init__.py +0 -0
  31. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/providers/base.py +0 -0
  32. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/providers/oauth2.py +0 -0
  33. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/providers/oidc.py +0 -0
  34. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/session.py +0 -0
  35. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/auth/token.py +0 -0
  36. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cache/__init__.py +0 -0
  37. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cache/decorator.py +0 -0
  38. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cache/strategies.py +0 -0
  39. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/__init__.py +0 -0
  40. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/app.py +0 -0
  41. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/__init__.py +0 -0
  42. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/__init__.py +0 -0
  43. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/check.py +0 -0
  44. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/common.py +0 -0
  45. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/login.py +0 -0
  46. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/logout.py +0 -0
  47. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/providers.py +0 -0
  48. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/status.py +0 -0
  49. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/token.py +0 -0
  50. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/auth/whoami.py +0 -0
  51. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/config.py +0 -0
  52. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/__init__.py +0 -0
  53. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/attach.py +0 -0
  54. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/common.py +0 -0
  55. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/list_sessions.py +0 -0
  56. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/logs.py +0 -0
  57. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/start.py +0 -0
  58. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/status.py +0 -0
  59. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/ops/stop.py +0 -0
  60. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/rapi/__init__.py +0 -0
  61. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/rapi/call.py +0 -0
  62. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/rapi/list.py +0 -0
  63. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/rapi/show.py +0 -0
  64. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/__init__.py +0 -0
  65. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/common.py +0 -0
  66. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/decrypt.py +0 -0
  67. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/doctor.py +0 -0
  68. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/encrypt.py +0 -0
  69. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/commands/secrets/shred.py +0 -0
  70. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/cli/common.py +0 -0
  71. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/exceptions.py +0 -0
  72. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/export.py +0 -0
  73. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/config/sops.py +0 -0
  74. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/__init__.py +0 -0
  75. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/aiosqlcipher.py +0 -0
  76. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/cipher.py +0 -0
  77. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/database.py +0 -0
  78. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/exceptions.py +0 -0
  79. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/db/pool.py +0 -0
  80. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/helpers/__init__.py +0 -0
  81. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/helpers/exceptions.py +0 -0
  82. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/helpers/time_trigger.py +0 -0
  83. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/limits.py +0 -0
  84. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/logging/__init__.py +0 -0
  85. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/logging/manager.py +0 -0
  86. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/__init__.py +0 -0
  87. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/exceptions.py +0 -0
  88. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/filesystem.py +0 -0
  89. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transport.py +0 -0
  90. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/__init__.py +0 -0
  91. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/gmail.py +0 -0
  92. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/resend.py +0 -0
  93. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/ses.py +0 -0
  94. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/mail/transports/smtp.py +0 -0
  95. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/metrics/__init__.py +0 -0
  96. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/metrics/decorators.py +0 -0
  97. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/metrics/exceptions.py +0 -0
  98. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/__init__.py +0 -0
  99. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/_styles.py +0 -0
  100. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/cell.py +0 -0
  101. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/config.py +0 -0
  102. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/delivery.py +0 -0
  103. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/exceptions.py +0 -0
  104. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/image.py +0 -0
  105. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/kv.py +0 -0
  106. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/list.py +0 -0
  107. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/metric.py +0 -0
  108. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/monitoring.py +0 -0
  109. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/renderer.py +0 -0
  110. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/service.py +0 -0
  111. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/table.py +0 -0
  112. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/monitoring/types.py +0 -0
  113. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/__init__.py +0 -0
  114. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/base.py +0 -0
  115. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/container.py +0 -0
  116. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/exceptions.py +0 -0
  117. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/manager.py +0 -0
  118. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/models.py +0 -0
  119. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/tmux.py +0 -0
  120. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ops/validators.py +0 -0
  121. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/__init__.py +0 -0
  122. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/base.py +0 -0
  123. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/exceptions.py +0 -0
  124. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/models.py +0 -0
  125. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/runner.py +0 -0
  126. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/__init__.py +0 -0
  127. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/_base.py +0 -0
  128. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/callable.py +0 -0
  129. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/python.py +0 -0
  130. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/steps/shell.py +0 -0
  131. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/pipeline/validators.py +0 -0
  132. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/py.typed +0 -0
  133. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/__init__.py +0 -0
  134. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/client.py +0 -0
  135. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/config.py +0 -0
  136. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/credentials.py +0 -0
  137. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/rapi/exceptions.py +0 -0
  138. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/__init__.py +0 -0
  139. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/circuit_breaker.py +0 -0
  140. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/exceptions.py +0 -0
  141. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/heartbeat.py +0 -0
  142. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/rate_limiter.py +0 -0
  143. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/shutdown.py +0 -0
  144. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/resilience/watchdog.py +0 -0
  145. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/__init__.py +0 -0
  146. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/exceptions.py +0 -0
  147. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/models.py +0 -0
  148. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/__init__.py +0 -0
  149. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/base.py +0 -0
  150. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/environment.py +0 -0
  151. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/keyring.py +0 -0
  152. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/kms.py +0 -0
  153. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/kwargs.py +0 -0
  154. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/providers/sops.py +0 -0
  155. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/resolver.py +0 -0
  156. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secrets/sensitive.py +0 -0
  157. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secure/__init__.py +0 -0
  158. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secure/fs.py +0 -0
  159. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/secure/permissions.py +0 -0
  160. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ssl.py +0 -0
  161. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/__init__.py +0 -0
  162. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/chain.py +0 -0
  163. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/config.py +0 -0
  164. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/exceptions.py +0 -0
  165. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/primitives.py +0 -0
  166. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/transform/validators.py +0 -0
  167. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/__init__.py +0 -0
  168. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/exceptions.py +0 -0
  169. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/panels.py +0 -0
  170. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/spinner.py +0 -0
  171. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/ui/tables.py +0 -0
  172. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/__init__.py +0 -0
  173. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/dict.py +0 -0
  174. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/formatting.py +0 -0
  175. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/http_trace.py +0 -0
  176. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/lazy.py +0 -0
  177. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/secure_delete.py +0 -0
  178. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/serialization.py +0 -0
  179. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/text.py +0 -0
  180. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/utils/validators.py +0 -0
  181. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/websocket/__init__.py +0 -0
  182. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/websocket/exceptions.py +0 -0
  183. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/websocket/manager.py +0 -0
  184. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib/websocket/models.py +0 -0
  185. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib.egg-info/SOURCES.txt +0 -0
  186. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib.egg-info/dependency_links.txt +0 -0
  187. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib.egg-info/entry_points.txt +0 -0
  188. {kstlib-2.2.1 → kstlib-2.3.0}/src/kstlib.egg-info/requires.txt +0 -0
  189. {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.2.1
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: svc_user
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.sender(...).to(...).message(...).send()
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(
@@ -39,7 +39,7 @@ __logo__ = (
39
39
  )
40
40
 
41
41
  __app_name__ = "kstlib"
42
- __version__ = "2.2.1"
42
+ __version__ = "2.3.0"
43
43
  __description__ = (
44
44
  "Config-driven helpers for Python projects (dynamic config, secure secrets, preset logging, and more…)"
45
45
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kstlib
3
- Version: 2.2.1
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