bitranox-template-py-cli 2.0.1__tar.gz → 2.1.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 (127) hide show
  1. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.env.example +14 -0
  2. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/CHANGELOG.md +21 -0
  3. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/PKG-INFO +5 -4
  4. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/README.md +3 -2
  5. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/docs/systemdesign/module_reference.md +6 -3
  6. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/pyproject.toml +2 -2
  7. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  8. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +28 -3
  9. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/email/config.py +10 -14
  10. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_email_config_translation.py +31 -0
  11. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.devcontainer/devcontainer.json +0 -0
  12. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.devcontainer/settings.json +0 -0
  13. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.gitattributes +0 -0
  14. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.github/actions/extract-metadata/action.yml +0 -0
  15. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.github/dependabot.yml +0 -0
  16. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.github/workflows/codeql.yml +0 -0
  17. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.github/workflows/default_cicd_public.yml +0 -0
  18. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.github/workflows/default_release_public.yml +0 -0
  19. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.gitignore +0 -0
  20. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.qlty/qlty.toml +0 -0
  21. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/.snyk +0 -0
  22. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/CONFIG.md +0 -0
  23. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/CONTRIBUTING.md +0 -0
  24. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/DEVELOPMENT.md +0 -0
  25. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/INSTALL.md +0 -0
  26. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/LICENSE +0 -0
  27. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/Makefile +0 -0
  28. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/SECURITY.md +0 -0
  29. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/ai-stance.md +0 -0
  30. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/ai-transparency.md +0 -0
  31. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/codecov.yml +0 -0
  32. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  33. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/notebooks/Quickstart.ipynb +0 -0
  34. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/rename.sh +0 -0
  35. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/rename_dry.sh +0 -0
  36. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/reset_git_history.sh +0 -0
  37. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/__init__.py +0 -0
  38. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/__main__.py +0 -0
  39. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  40. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  41. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  42. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +0 -0
  43. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
  44. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +0 -0
  45. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +0 -0
  46. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -0
  47. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  48. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  49. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/config_load.py +0 -0
  50. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  51. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  52. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
  53. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  54. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  55. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  56. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
  57. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
  58. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  59. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  60. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  61. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  62. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  63. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  64. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  65. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
  66. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  67. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  68. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  69. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  70. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/email/transport.py +0 -0
  71. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  72. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  73. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  74. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/logging/setup.py +0 -0
  75. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/memory/__init__.py +0 -0
  76. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  77. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
  78. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/memory/logging.py +0 -0
  79. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  80. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  81. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/application/ports.py +0 -0
  82. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/application/py.typed +0 -0
  83. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/composition/__init__.py +0 -0
  84. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  85. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  86. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  87. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  88. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  89. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  90. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/entry.py +0 -0
  91. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/src/bitranox_template_py_cli/py.typed +0 -0
  92. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/conftest.py +0 -0
  93. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_behaviors.py +0 -0
  94. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cache_effectiveness.py +0 -0
  95. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_config.py +0 -0
  96. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_config_errors.py +0 -0
  97. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_core.py +0 -0
  98. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_email.py +0 -0
  99. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_email_config_errors.py +0 -0
  100. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_env_file.py +0 -0
  101. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_exit_codes.py +0 -0
  102. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_main_exit.py +0 -0
  103. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_overrides.py +0 -0
  104. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_cli_validation.py +0 -0
  105. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_config_overrides.py +0 -0
  106. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_declared_dependencies.py +0 -0
  107. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_deploy_mode_safety.py +0 -0
  108. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_deploy_permissions.py +0 -0
  109. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_display.py +0 -0
  110. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_email_attachment_lists.py +0 -0
  111. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_email_password_secrecy.py +0 -0
  112. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_email_shipped_defaults.py +0 -0
  113. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_enums.py +0 -0
  114. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_errors.py +0 -0
  115. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_logging.py +0 -0
  116. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_logging_dotenv_isolation.py +0 -0
  117. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_mail.py +0 -0
  118. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_memory_logging.py +0 -0
  119. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_metadata.py +0 -0
  120. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_metadata_sync.py +0 -0
  121. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_module_entry.py +0 -0
  122. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_module_reference_sync.py +0 -0
  123. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_permission_defaults.py +0 -0
  124. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_ports.py +0 -0
  125. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_property_email.py +0 -0
  126. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_property_overrides.py +0 -0
  127. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.0}/tests/test_safe_console.py +0 -0
@@ -101,6 +101,20 @@ GITHUB_TOKEN=
101
101
  # .env: EMAIL__LOCAL_HOSTNAME=mail.example.com
102
102
  # EMAIL__LOCAL_HOSTNAME=mail.example.com
103
103
 
104
+ # Purpose: Upper bound in seconds for one SMTP session (connect to QUIT); 0 for none
105
+ # Type: float
106
+ # Default: 0 (none)
107
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__DELIVERY_DEADLINE=300
108
+ # .env: EMAIL__DELIVERY_DEADLINE=300
109
+ # EMAIL__DELIVERY_DEADLINE=300
110
+
111
+ # Purpose: Most recipients one send accepts; 0 for no limit
112
+ # Type: integer
113
+ # Default: 1000
114
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__RECIPIENT_MAX_COUNT=1000
115
+ # .env: EMAIL__RECIPIENT_MAX_COUNT=1000
116
+ # EMAIL__RECIPIENT_MAX_COUNT=1000
117
+
104
118
  # Purpose: Raise FileNotFoundError when attachment files are missing
105
119
  # Type: boolean (true/false)
106
120
  # Default: true
@@ -6,6 +6,27 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.0] 2026-10-06 15:37:27
10
+
11
+ ### Added
12
+ - **Three email limits from btx_lib_mail 4.0.0 are configurable**: `[email] delivery_deadline`
13
+ (an upper bound in seconds for one SMTP session; the field is `smtp_delivery_deadline`),
14
+ `[email] recipient_max_count` (default 1000) and `[email.attachments] max_count` (default
15
+ 100). A value of 0 means no limit, as `max_size_bytes = 0` already did. Shipped in
16
+ `50-mail.toml`, `.env.example` and the module reference.
17
+
18
+ ### Changed
19
+ - **Requires `btx_lib_mail>=4.0.0`.** Effects a user of the email commands can see: the
20
+ Windows dangerous extensions (`.exe`, `.bat`, `.ps1`, ...) are refused on every platform by
21
+ default and the POSIX ones on Windows; a sender or recipient longer than RFC 5321 allows is
22
+ refused; a subject with a control character or over 4096 characters is refused; an
23
+ attachment swapped after its checks is refused; a delivery error is a `DeliveryError` (still
24
+ a `RuntimeError`). See the btx_lib_mail 4.0.0 changelog for the full list.
25
+
26
+ ### Removed
27
+ - **`EmailConfig`'s own SMTP host validator.** `ConfMail` checks every host's syntax (port
28
+ range, IPv6 brackets) itself since btx_lib_mail 3.0.1; the messages are unchanged.
29
+
9
30
  ## [2.0.1] 2026-10-06 13:09:50
10
31
 
11
32
  Re-release of 2.0.0, which was tagged but never published: PyPI rejected the upload because
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bitranox_template_py_cli
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: Template CLI application with configuration management and structured logging
5
5
  Project-URL: Homepage, https://github.com/bitranox/bitranox_template_py_cli
6
6
  Project-URL: Repository, https://github.com/bitranox/bitranox_template_py_cli.git
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.10
24
- Requires-Dist: btx-lib-mail>=3.0.1
24
+ Requires-Dist: btx-lib-mail>=4.0.0
25
25
  Requires-Dist: click>=8.5.0
26
26
  Requires-Dist: lib-cli-exit-tools>=2.4.0
27
27
  Requires-Dist: lib-layered-config>=7.0.1
@@ -286,6 +286,7 @@ use_starttls = true
286
286
  starttls_verify = true # false accepts any certificate: only for a known internal relay
287
287
  timeout = 60.0
288
288
  # local_hostname = "mail.example.com" # EHLO name; set it where reverse DNS is slow
289
+ # delivery_deadline = 300 # upper bound in seconds for one SMTP session; 0 = none
289
290
  ```
290
291
 
291
292
  A key `[email]` or `[email.attachments]` does not know is refused (exit 78,
@@ -378,8 +379,8 @@ from bitranox_template_py_cli.composition import send_email, send_notification
378
379
 
379
380
  # Configure email. EmailConfig is a btx_lib_mail ConfMail, so in Python the fields use the
380
381
  # library's names (smtphosts, smtp_use_starttls, smtp_timeout, smtp_starttls_verify,
381
- # smtp_local_hostname); configuration files keep their keys (smtp_hosts, use_starttls, timeout,
382
- # starttls_verify, local_hostname). smtp_password is a SecretStr: it prints as
382
+ # smtp_local_hostname, smtp_delivery_deadline); configuration files keep their keys (smtp_hosts,
383
+ # use_starttls, timeout, starttls_verify, local_hostname, delivery_deadline). smtp_password is a SecretStr: it prints as
383
384
  # '**********' in logs, reprs and dumps, and config.smtp_password.get_secret_value() returns
384
385
  # the plain text.
385
386
  config = EmailConfig(
@@ -231,6 +231,7 @@ use_starttls = true
231
231
  starttls_verify = true # false accepts any certificate: only for a known internal relay
232
232
  timeout = 60.0
233
233
  # local_hostname = "mail.example.com" # EHLO name; set it where reverse DNS is slow
234
+ # delivery_deadline = 300 # upper bound in seconds for one SMTP session; 0 = none
234
235
  ```
235
236
 
236
237
  A key `[email]` or `[email.attachments]` does not know is refused (exit 78,
@@ -323,8 +324,8 @@ from bitranox_template_py_cli.composition import send_email, send_notification
323
324
 
324
325
  # Configure email. EmailConfig is a btx_lib_mail ConfMail, so in Python the fields use the
325
326
  # library's names (smtphosts, smtp_use_starttls, smtp_timeout, smtp_starttls_verify,
326
- # smtp_local_hostname); configuration files keep their keys (smtp_hosts, use_starttls, timeout,
327
- # starttls_verify, local_hostname). smtp_password is a SecretStr: it prints as
327
+ # smtp_local_hostname, smtp_delivery_deadline); configuration files keep their keys (smtp_hosts,
328
+ # use_starttls, timeout, starttls_verify, local_hostname, delivery_deadline). smtp_password is a SecretStr: it prints as
328
329
  # '**********' in logs, reprs and dumps, and config.smtp_password.get_secret_value() returns
329
330
  # the plain text.
330
331
  config = EmailConfig(
@@ -337,6 +337,8 @@ column).
337
337
  | `smtp_starttls_verify` | `bool` | `True` | `starttls_verify` | Verify the server certificate after STARTTLS |
338
338
  | `smtp_timeout` | `float` | `30.0` | `timeout` | Socket timeout in seconds |
339
339
  | `smtp_local_hostname` | `str \| None` | `None` | `local_hostname` | Host name announced in EHLO; `None` looks it up once per process |
340
+ | `smtp_delivery_deadline` | `float \| None` | `None` | `delivery_deadline` | Upper bound in seconds for one SMTP session; `None` for none |
341
+ | `recipient_max_count` | `int \| None` | `1000` | `recipient_max_count` | Most recipients one send accepts; `None` lifts the limit |
340
342
  | `raise_on_missing_attachments` | `bool` | `True` | `raise_on_missing_attachments` | Raise on missing attachment files |
341
343
  | `raise_on_invalid_recipient` | `bool` | `True` | `raise_on_invalid_recipient` | Raise on invalid recipient addresses |
342
344
 
@@ -345,10 +347,11 @@ column).
345
347
  | Field | Type | Default | File key (`[email.attachments]`) | Description |
346
348
  |------------------------------------------|---------------------------|--------------|----------------------------------|---------------------------------------------------------|
347
349
  | `attachment_allowed_extensions` | `frozenset[str] \| None` | `None` | `allowed_extensions` | Whitelist of allowed extensions |
348
- | `attachment_blocked_extensions` | `frozenset[str]` | OS defaults | `blocked_extensions` | Blacklist of blocked extensions |
350
+ | `attachment_blocked_extensions` | `frozenset[str]` | POSIX + Win | `blocked_extensions` | Blacklist of blocked extensions (both lists, every OS) |
349
351
  | `attachment_allowed_directories` | `frozenset[Path] \| None` | `None` | `allowed_directories` | Whitelist of allowed source directories |
350
352
  | `attachment_blocked_directories` | `frozenset[Path]` | OS defaults | `blocked_directories` | Blacklist of blocked directories |
351
353
  | `attachment_max_size_bytes` | `int \| None` | `26_214_400` | `max_size_bytes` | Maximum file size (25 MiB), `None` to disable |
354
+ | `attachment_max_count` | `int \| None` | `100` | `max_count` | Most attachments one send accepts, `None` to disable |
352
355
  | `attachment_allow_symlinks` | `bool` | `False` | `allow_symlinks` | Whether symlinks are permitted |
353
356
  | `attachment_raise_on_security_violation` | `bool` | `True` | `raise_on_security_violation` | Raise or skip on security violation |
354
357
  | `attachment_allow_empty_blocklists` | `bool` | `False` | not exposed | Allow an empty blocked set (blocks nothing) from Python |
@@ -359,13 +362,13 @@ lib_layered_config is the only reader of configuration: it merges every layer (t
359
362
  defaults, the app, host and user files, `.env`, the environment, `--set`) into one mapping.
360
363
  `load_email_config_from_dict()` turns that mapping's `[email]` section into an `EmailConfig`:
361
364
 
362
- - the five keys in the third column that differ from the field names are mapped;
365
+ - the six keys in the third column that differ from the field names are mapped;
363
366
  - `[email.attachments]` keys become `attachment_<key>`;
364
367
  - a key that is not listed is refused (exit 78, `email.<key>: unknown key`), whatever layer it came from;
365
368
  - blank text means "not configured"; a single host or address string is a one-entry list;
366
369
  - an empty attachment list (`[]` or a blank string) means the library's defaults, and any other
367
370
  value that is not a list, such as a comma-separated string, is refused, as is a blank entry;
368
- - `max_size_bytes = 0` means no size limit.
371
+ - a limit of 0 (`max_size_bytes`, `max_count`, `recipient_max_count`, `delivery_deadline`) means no limit.
369
372
 
370
373
  `describe_validation_error()` renders a refusal as one `email.<file key>: <reason>` line per
371
374
  problem, never with the refused value.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bitranox_template_py_cli"
3
- version = "2.0.1"
3
+ version = "2.1.0"
4
4
  description = "Template CLI application with configuration management and structured logging"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -12,7 +12,7 @@ dependencies = [
12
12
  "lib_log_rich>=6.3.9",
13
13
  "python-dotenv>=1.2.4",
14
14
  "lib_layered_config>=7.0.1",
15
- "btx_lib_mail>=3.0.1",
15
+ "btx_lib_mail>=4.0.0",
16
16
  "pydantic>=2.13.5",
17
17
  "orjson>=3.12.0",
18
18
  ]
@@ -40,7 +40,7 @@ name = "bitranox_template_py_cli"
40
40
  #: Human-readable summary shown in CLI help output.
41
41
  title = "Template CLI application with configuration management and structured logging"
42
42
  #: Current release version pulled from ``pyproject.toml`` by automation.
43
- version = "2.0.1"
43
+ version = "2.1.0"
44
44
  #: Repository homepage presented to users.
45
45
  homepage = "https://github.com/bitranox/bitranox_template_py_cli"
46
46
  #: Author attribution surfaced in CLI output.
@@ -110,6 +110,23 @@ starttls_verify = true
110
110
  # qualified name here.
111
111
  local_hostname = ""
112
112
 
113
+ # Purpose: Upper bound in seconds for one SMTP session, from the connect to QUIT
114
+ # Type: float, or 0 for no deadline
115
+ # Default: 0 (none; only the socket timeout bounds each read or write)
116
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__DELIVERY_DEADLINE=300
117
+ # .env: EMAIL__DELIVERY_DEADLINE=300
118
+ # Note: The socket timeout restarts with every byte, so a server answering one byte at a
119
+ # time can hold a session open indefinitely; past the deadline that host counts as failed.
120
+ delivery_deadline = 0
121
+
122
+ # Purpose: Most recipients one send accepts (counted after duplicates are dropped)
123
+ # Type: integer, or 0 for no limit
124
+ # Default: 1000
125
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__RECIPIENT_MAX_COUNT=1000
126
+ # .env: EMAIL__RECIPIENT_MAX_COUNT=1000
127
+ # Note: More recipients are refused before anything is delivered
128
+ recipient_max_count = 1000
129
+
113
130
  # Purpose: Behavior when attachment files are missing
114
131
  # Type: boolean
115
132
  # Default: true (raise FileNotFoundError)
@@ -152,9 +169,9 @@ allowed_extensions = []
152
169
 
153
170
  # Purpose: Blacklist of blocked file extensions (ignored when allowed_extensions is set)
154
171
  # Type: array of strings
155
- # Default: [] (the library's defaults apply: btx_lib_mail DANGEROUS_EXTENSIONS_POSIX /
156
- # DANGEROUS_EXTENSIONS_WINDOWS; paths matching SENSITIVE_PATH_PATTERNS such as /.ssh/ are
157
- # always refused)
172
+ # Default: [] (the library's defaults apply: btx_lib_mail DANGEROUS_EXTENSIONS_POSIX plus
173
+ # DANGEROUS_EXTENSIONS_WINDOWS on every platform; paths matching SENSITIVE_PATH_PATTERNS such
174
+ # as /.ssh/ are always refused)
158
175
  # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__ATTACHMENTS__BLOCKED_EXTENSIONS='[".exe",".bat",".cmd"]'
159
176
  # .env: EMAIL__ATTACHMENTS__BLOCKED_EXTENSIONS=[".exe",".bat",".cmd"] (unquoted)
160
177
  # A comma-separated value is NOT a list: it arrives as one string and is refused
@@ -197,6 +214,14 @@ blocked_directories = []
197
214
  # Note: Set to 0 to disable size checking entirely
198
215
  max_size_bytes = 26214400
199
216
 
217
+ # Purpose: Most attachments one send accepts
218
+ # Type: integer, or 0 for no limit
219
+ # Default: 100
220
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__ATTACHMENTS__MAX_COUNT=100
221
+ # .env: EMAIL__ATTACHMENTS__MAX_COUNT=100
222
+ # Note: More attachments are refused before any file is checked or anything is delivered
223
+ max_count = 100
224
+
200
225
  # Purpose: Whether to allow symbolic links as attachments
201
226
  # Type: boolean
202
227
  # Default: false (symlinks are rejected)
@@ -3,7 +3,7 @@
3
3
  lib_layered_config is the only reader of configuration. It merges every layer (the shipped
4
4
  defaults, the app, host and user files, ``.env``, the environment and ``--set``) into one
5
5
  mapping, and :func:`load_email_config_from_dict` turns that mapping's ``[email]`` section into
6
- :class:`EmailConfig`. Operators keep writing the file keys they always wrote; five of them
6
+ :class:`EmailConfig`. Operators keep writing the file keys they always wrote; six of them
7
7
  differ from the field names code reads (``FILE_KEY_TO_FIELD``). A key that is not a file key
8
8
  is refused, so a typo cannot leave a setting silently at its default.
9
9
  """
@@ -14,7 +14,7 @@ import re
14
14
  from collections.abc import Mapping
15
15
  from typing import TYPE_CHECKING, Any, cast
16
16
 
17
- from btx_lib_mail import ConfMail, validate_email_address, validate_smtp_host
17
+ from btx_lib_mail import ConfMail, validate_email_address
18
18
  from pydantic import ConfigDict, Field, SecretStr, ValidationError, field_validator
19
19
 
20
20
  if TYPE_CHECKING:
@@ -34,6 +34,7 @@ FILE_KEY_TO_FIELD: dict[str, str] = {
34
34
  "timeout": "smtp_timeout",
35
35
  "starttls_verify": "smtp_starttls_verify",
36
36
  "local_hostname": "smtp_local_hostname",
37
+ "delivery_deadline": "smtp_delivery_deadline",
37
38
  }
38
39
  _FIELD_TO_FILE_KEY: dict[str, str] = {field: key for key, field in FILE_KEY_TO_FIELD.items()}
39
40
 
@@ -47,6 +48,7 @@ SECTION_KEYS = frozenset(
47
48
  "smtp_password",
48
49
  "raise_on_missing_attachments",
49
50
  "raise_on_invalid_recipient",
51
+ "recipient_max_count",
50
52
  _ATTACHMENTS,
51
53
  }
52
54
  )
@@ -59,6 +61,7 @@ ATTACHMENT_KEYS = frozenset(
59
61
  "allowed_directories",
60
62
  "blocked_directories",
61
63
  "max_size_bytes",
64
+ "max_count",
62
65
  "allow_symlinks",
63
66
  "raise_on_security_violation",
64
67
  }
@@ -74,6 +77,9 @@ _BLANK_TEXT_MEANS_UNSET = frozenset(
74
77
  _EMPTY_LIST_MEANS_DEFAULT = frozenset(
75
78
  {"allowed_extensions", "blocked_extensions", "allowed_directories", "blocked_directories"}
76
79
  )
80
+ #: Limits whose 0 means "no limit". ConfMail refuses 0 and lifts a limit with None, which a
81
+ #: TOML file or an environment variable cannot write.
82
+ _ZERO_MEANS_NO_LIMIT = frozenset({"max_size_bytes", "max_count", "recipient_max_count", "delivery_deadline"})
77
83
  #: The refusal of a blank entry in an attachment list.
78
84
  _BLANK_ENTRY = ValueError("an entry is blank; remove it")
79
85
  #: The form an attachment list must take, named by the refusal of any other form. A
@@ -146,16 +152,6 @@ class EmailConfig(ConfMail):
146
152
  return [value] if value.strip() else []
147
153
  return value
148
154
 
149
- @field_validator("smtphosts")
150
- @classmethod
151
- def _check_hosts(cls, value: list[str]) -> list[str]:
152
- # ConfMail refuses userinfo, a path and control characters; the port range and the
153
- # IPv6 brackets are checked only by validate_smtp_host, so a typo surfaces at load
154
- # time rather than at the first delivery.
155
- for host in value:
156
- validate_smtp_host(host)
157
- return value
158
-
159
155
  @field_validator("from_address")
160
156
  @classmethod
161
157
  def _check_from_address(cls, value: str | None) -> str | None:
@@ -273,10 +269,10 @@ def _means_unset(key: str, value: object) -> bool:
273
269
 
274
270
 
275
271
  def _field_value(key: str, value: object) -> object:
276
- """A lone host or address becomes a one-entry list; a size limit of 0 means no limit."""
272
+ """A lone host or address becomes a one-entry list; a limit of 0 means no limit."""
277
273
  if key in _ONE_OR_MANY and isinstance(value, str):
278
274
  return [value]
279
- if key == "max_size_bytes" and type(value) in (int, float) and value == 0:
275
+ if key in _ZERO_MEANS_NO_LIMIT and type(value) in (int, float) and value == 0:
280
276
  return None
281
277
  return value
282
278
 
@@ -108,6 +108,37 @@ def test_a_size_limit_of_zero_means_no_limit() -> None:
108
108
  assert _load({"attachments": {"max_size_bytes": 0}}).attachment_max_size_bytes is None
109
109
 
110
110
 
111
+ @pytest.mark.os_agnostic
112
+ @pytest.mark.parametrize(
113
+ ("email", "field"),
114
+ [
115
+ ({"recipient_max_count": 0}, "recipient_max_count"),
116
+ ({"delivery_deadline": 0}, "smtp_delivery_deadline"),
117
+ ({"delivery_deadline": 0.0}, "smtp_delivery_deadline"),
118
+ ({"attachments": {"max_count": 0}}, "attachment_max_count"),
119
+ ],
120
+ ids=["recipient_max_count", "delivery_deadline", "delivery_deadline_float", "attachments.max_count"],
121
+ )
122
+ def test_every_other_limit_of_zero_means_no_limit(email: dict[str, Any], field: str) -> None:
123
+ assert getattr(_load(email), field) is None
124
+
125
+
126
+ @pytest.mark.os_agnostic
127
+ def test_the_limits_reach_their_fields() -> None:
128
+ config = _load({"recipient_max_count": 5, "delivery_deadline": 120, "attachments": {"max_count": 3}})
129
+
130
+ assert (config.recipient_max_count, config.smtp_delivery_deadline, config.attachment_max_count) == (5, 120.0, 3)
131
+
132
+
133
+ @pytest.mark.os_agnostic
134
+ def test_a_negative_deadline_is_named_by_its_file_key() -> None:
135
+ (line,) = _problems({"delivery_deadline": -1})
136
+
137
+ assert line.startswith("email.delivery_deadline: ")
138
+ assert "smtp_delivery_deadline" not in line
139
+ assert "unknown key" not in line
140
+
141
+
111
142
  @pytest.mark.os_agnostic
112
143
  @pytest.mark.parametrize(
113
144
  ("email", "line"),