bitranox-template-py-cli 2.1.1__tar.gz → 2.1.2__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 (128) hide show
  1. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/CHANGELOG.md +16 -0
  2. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/PKG-INFO +2 -2
  3. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/docs/systemdesign/module_reference.md +16 -14
  4. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/pyproject.toml +2 -2
  5. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  6. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +28 -2
  7. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +3 -0
  8. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +2 -1
  9. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/email/transport.py +4 -0
  10. bitranox_template_py_cli-2.1.2/tests/test_cli_attachment_refused.py +191 -0
  11. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.devcontainer/devcontainer.json +0 -0
  12. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.devcontainer/settings.json +0 -0
  13. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.env.example +0 -0
  14. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.gitattributes +0 -0
  15. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.github/actions/extract-metadata/action.yml +0 -0
  16. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.github/dependabot.yml +0 -0
  17. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.github/workflows/codeql.yml +0 -0
  18. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.github/workflows/default_cicd_public.yml +0 -0
  19. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.github/workflows/default_release_public.yml +0 -0
  20. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.gitignore +0 -0
  21. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.qlty/qlty.toml +0 -0
  22. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/.snyk +0 -0
  23. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/CONFIG.md +0 -0
  24. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/CONTRIBUTING.md +0 -0
  25. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/DEVELOPMENT.md +0 -0
  26. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/INSTALL.md +0 -0
  27. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/LICENSE +0 -0
  28. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/Makefile +0 -0
  29. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/README.md +0 -0
  30. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/SECURITY.md +0 -0
  31. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/ai-stance.md +0 -0
  32. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/ai-transparency.md +0 -0
  33. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/codecov.yml +0 -0
  34. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  35. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/notebooks/Quickstart.ipynb +0 -0
  36. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/rename.sh +0 -0
  37. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/rename_dry.sh +0 -0
  38. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/reset_git_history.sh +0 -0
  39. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/__init__.py +0 -0
  40. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/__main__.py +0 -0
  41. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  42. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  43. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  44. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +0 -0
  45. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
  46. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +0 -0
  47. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -0
  48. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  49. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  50. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/config_load.py +0 -0
  51. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  52. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  53. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  54. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  55. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  56. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
  57. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
  58. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  59. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  60. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  61. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  62. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  63. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  64. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  65. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
  66. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  67. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  68. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/email/config.py +0 -0
  69. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  70. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  71. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  72. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  73. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  74. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/logging/setup.py +0 -0
  75. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/memory/__init__.py +0 -0
  76. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  77. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
  78. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/memory/logging.py +0 -0
  79. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  80. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  81. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/application/ports.py +0 -0
  82. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/application/py.typed +0 -0
  83. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/composition/__init__.py +0 -0
  84. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  85. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  86. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  87. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  88. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  89. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  90. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/entry.py +0 -0
  91. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/src/bitranox_template_py_cli/py.typed +0 -0
  92. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/conftest.py +0 -0
  93. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_behaviors.py +0 -0
  94. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cache_effectiveness.py +0 -0
  95. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_config.py +0 -0
  96. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_config_errors.py +0 -0
  97. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_core.py +0 -0
  98. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_email.py +0 -0
  99. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_email_config_errors.py +0 -0
  100. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_env_file.py +0 -0
  101. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_exit_codes.py +0 -0
  102. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_main_exit.py +0 -0
  103. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_overrides.py +0 -0
  104. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_cli_validation.py +0 -0
  105. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_config_overrides.py +0 -0
  106. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_declared_dependencies.py +0 -0
  107. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_deploy_mode_safety.py +0 -0
  108. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_deploy_permissions.py +0 -0
  109. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_display.py +0 -0
  110. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_email_attachment_lists.py +0 -0
  111. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_email_config_translation.py +0 -0
  112. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_email_password_secrecy.py +0 -0
  113. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_email_shipped_defaults.py +0 -0
  114. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_enums.py +0 -0
  115. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_errors.py +0 -0
  116. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_logging.py +0 -0
  117. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_logging_dotenv_isolation.py +0 -0
  118. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_mail.py +0 -0
  119. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_memory_logging.py +0 -0
  120. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_metadata.py +0 -0
  121. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_metadata_sync.py +0 -0
  122. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_module_entry.py +0 -0
  123. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_module_reference_sync.py +0 -0
  124. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_permission_defaults.py +0 -0
  125. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_ports.py +0 -0
  126. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_property_email.py +0 -0
  127. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_property_overrides.py +0 -0
  128. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.2}/tests/test_safe_console.py +0 -0
@@ -6,6 +6,22 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.2] 2026-10-08 09:43:32
10
+
11
+ ### Changed
12
+ - **A refused attachment exits 77, not 1 with a traceback (exit code change).** An attachment
13
+ btx_lib_mail's security checks refuse (a blocked extension such as `.exe`, a file under a
14
+ blocked directory, a symlink, a file over the size limit) made `send-email` exit 1 with
15
+ `Error: Unexpected error - Attachment security violation (...)` and log the refusal with a
16
+ traceback, as if the program had crashed. Since btx_lib_mail 4.0.0 every blocked extension is
17
+ refused on every platform, so this is an ordinary outcome on Linux and macOS too. It now exits
18
+ 77 (`ExitCode.ATTACHMENT_REFUSED`, sysexits `EX_NOPERM`: not permitted by policy) with one
19
+ line, `Error: Attachment refused by security policy - <the library's reason>`, logs no
20
+ traceback, and delivers nothing. A script that checks for 1 after a refused attachment must
21
+ check for 77. With `email.attachments.raise_on_security_violation = false` the attachment is
22
+ still skipped with a warning and the message sent, as before; a violation that names no attachment
23
+ left to drop is refused in warn mode too, and now exits 77 where it exited 1.
24
+
9
25
  ## [2.1.1] 2026-10-06 18:13:05
10
26
 
11
27
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bitranox_template_py_cli
3
- Version: 2.1.1
3
+ Version: 2.1.2
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
@@ -26,7 +26,7 @@ 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
28
28
  Requires-Dist: lib-log-rich>=6.3.9
29
- Requires-Dist: orjson>=3.12.0
29
+ Requires-Dist: orjson>=3.13.0
30
30
  Requires-Dist: pydantic>=2.13.5
31
31
  Requires-Dist: python-dotenv>=1.2.4
32
32
  Requires-Dist: rich-click>=1.9.9
@@ -70,6 +70,7 @@ moves or removes a module, a CLI option or a public name.
70
70
  - `tests/conftest.py` - Shared fixtures (see Test Fixtures below)
71
71
  - `tests/test_behaviors.py` - Domain function tests
72
72
  - `tests/test_cache_effectiveness.py` - Configuration loading returns consistent (cached) results
73
+ - `tests/test_cli_attachment_refused.py` - An attachment btx_lib_mail's security checks refuse exits 77 with one `Error:` line, nothing delivered
73
74
  - `tests/test_cli_config.py` - `config`, `config-deploy`, `config-generate-examples` commands
74
75
  - `tests/test_cli_config_errors.py` - A configuration that cannot be loaded: who reports it, and who still runs
75
76
  - `tests/test_cli_core.py` - Traceback, main entry, help, hello, fail, info, unknown command
@@ -136,19 +137,20 @@ Run `lint-imports` to verify compliance.
136
137
 
137
138
  POSIX-conventional exit codes defined in `adapters/cli/exit_codes.py`:
138
139
 
139
- | Code | Name | Usage |
140
- |------|---------------------|------------------------------------------------------------------------------------------------------------------------|
141
- | 0 | `SUCCESS` | Command completed successfully |
142
- | 1 | `GENERAL_ERROR` | Unhandled exception, general failure |
143
- | 2 | `FILE_NOT_FOUND` | Attachment or file not found; also click's usage error (bad option value, malformed `--set`, invalid `--profile` name) |
144
- | 13 | `PERMISSION_DENIED` | Cannot write to target directory |
145
- | 22 | `INVALID_ARGUMENT` | Invalid CLI argument or section not found |
146
- | 69 | `SMTP_FAILURE` | SMTP delivery failed |
147
- | 78 | `CONFIG_ERROR` | Configuration missing, not loadable or invalid |
148
- | 110 | `TIMEOUT` | Operation timed out |
149
- | 130 | `SIGNAL_INT` | Interrupted (SIGINT/Ctrl+C) |
150
- | 141 | `BROKEN_PIPE` | Output pipe closed |
151
- | 143 | `SIGNAL_TERM` | Terminated (SIGTERM) |
140
+ | Code | Name | Usage |
141
+ |------|----------------------|------------------------------------------------------------------------------------------------------------------------|
142
+ | 0 | `SUCCESS` | Command completed successfully |
143
+ | 1 | `GENERAL_ERROR` | Unhandled exception, general failure |
144
+ | 2 | `FILE_NOT_FOUND` | Attachment or file not found; also click's usage error (bad option value, malformed `--set`, invalid `--profile` name) |
145
+ | 13 | `PERMISSION_DENIED` | Cannot write to target directory |
146
+ | 22 | `INVALID_ARGUMENT` | Invalid CLI argument or section not found |
147
+ | 69 | `SMTP_FAILURE` | SMTP delivery failed |
148
+ | 77 | `ATTACHMENT_REFUSED` | Attachment refused by btx_lib_mail's security checks (blocked extension or directory, symlink, size, ...) |
149
+ | 78 | `CONFIG_ERROR` | Configuration missing, not loadable or invalid |
150
+ | 110 | `TIMEOUT` | Operation timed out |
151
+ | 130 | `SIGNAL_INT` | Interrupted (SIGINT/Ctrl+C) |
152
+ | 141 | `BROKEN_PIPE` | Output pipe closed |
153
+ | 143 | `SIGNAL_TERM` | Terminated (SIGTERM) |
152
154
 
153
155
  ---
154
156
 
@@ -243,7 +245,7 @@ Send email using configured SMTP settings.
243
245
  | `--raise-on-missing-attachments / --no-raise-on-missing-attachments` | Override missing-attachment handling |
244
246
  | `--raise-on-invalid-recipient / --no-raise-on-invalid-recipient` | Override invalid-recipient handling |
245
247
 
246
- **Exit codes:** 0, 2 (file not found, or usage error), 22 (invalid option value), 69 (SMTP failure), 78 (no SMTP hosts, an invalid `[email]` section, or configuration not loadable)
248
+ **Exit codes:** 0, 2 (file not found, or usage error), 22 (invalid option value), 69 (SMTP failure), 77 (an attachment refused by btx_lib_mail's security checks), 78 (no SMTP hosts, an invalid `[email]` section, or configuration not loadable)
247
249
 
248
250
  ### send-notification
249
251
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bitranox_template_py_cli"
3
- version = "2.1.1"
3
+ version = "2.1.2"
4
4
  description = "Template CLI application with configuration management and structured logging"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ dependencies = [
14
14
  "lib_layered_config>=7.0.1",
15
15
  "btx_lib_mail>=4.0.0",
16
16
  "pydantic>=2.13.5",
17
- "orjson>=3.12.0",
17
+ "orjson>=3.13.0",
18
18
  ]
19
19
  license = { text = "MIT" }
20
20
  authors = [{ name = "bitranox", email = "bitranox@gmail.com" }]
@@ -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.1.1"
43
+ version = "2.1.2"
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.
@@ -11,6 +11,7 @@ import logging
11
11
  import os
12
12
  from typing import TYPE_CHECKING, Any, NoReturn, cast
13
13
 
14
+ from btx_lib_mail import AttachmentSecurityError
14
15
  from pydantic import ValidationError
15
16
 
16
17
  from bitranox_template_py_cli import __init__conf__
@@ -169,8 +170,10 @@ def execute_with_email_error_handling(
169
170
  1. ConfigurationError -> CONFIG_ERROR (78): Missing/invalid config
170
171
  2. ValueError -> INVALID_ARGUMENT (22): Invalid parameters or email format
171
172
  3. FileNotFoundError -> FILE_NOT_FOUND (2): Missing attachment (if enabled)
172
- 4. DeliveryError/RuntimeError -> SMTP_FAILURE (69): SMTP transport failures
173
- 5. Exception (catch-all) -> GENERAL_ERROR (1): Unexpected errors with traceback
173
+ 4. AttachmentSecurityError -> ATTACHMENT_REFUSED (77): An attachment btx_lib_mail's
174
+ security checks refuse (blocked extension or directory, symlink, size, ...)
175
+ 5. DeliveryError/RuntimeError -> SMTP_FAILURE (69): SMTP transport failures
176
+ 6. Exception (catch-all) -> GENERAL_ERROR (1): Unexpected errors with traceback
174
177
 
175
178
  This ordering ensures specific exceptions aren't caught by broader handlers.
176
179
  When adding new exception types, insert them before the catch-all Exception handler.
@@ -205,6 +208,8 @@ def execute_with_email_error_handling(
205
208
  "Attachment file not found",
206
209
  exit_code=ExitCode.FILE_NOT_FOUND,
207
210
  )
211
+ except AttachmentSecurityError as exc:
212
+ _refuse_attachment(exc)
208
213
  except (DeliveryError, RuntimeError) as exc:
209
214
  _handle_send_error(
210
215
  exc,
@@ -264,6 +269,27 @@ def _refuse_email_config(exc: ValidationError, heading: str, *, exit_code: ExitC
264
269
  get_current_context().exit(exit_code)
265
270
 
266
271
 
272
+ def _refuse_attachment(exc: AttachmentSecurityError) -> NoReturn:
273
+ """Report an attachment btx_lib_mail refused on security grounds, then exit 77.
274
+
275
+ The refusal is policy working as configured, not a crash, so no traceback is logged. The
276
+ user sees the library's reason, which names the violation and the resolved path; it never
277
+ carries a credential.
278
+
279
+ Args:
280
+ exc: The refusal btx_lib_mail raised.
281
+
282
+ Raises:
283
+ click.exceptions.Exit: Always raised, with the ATTACHMENT_REFUSED exit code.
284
+ """
285
+ logger.error(
286
+ "Attachment refused by security policy",
287
+ extra={"reason": exc.reason, "violation_type": exc.violation_type.value},
288
+ )
289
+ safe_console.echo(f"\nError: Attachment refused by security policy - {exc.reason}", err=True)
290
+ get_current_context().exit(ExitCode.ATTACHMENT_REFUSED)
291
+
292
+
267
293
  def _handle_send_result(result: bool, recipients: list[str] | None, message_type: str) -> None:
268
294
  """Handle the result of a send operation.
269
295
 
@@ -27,6 +27,8 @@ class ExitCode(IntEnum):
27
27
  * 2-13: errno-derived codes (ENOENT, EACCES)
28
28
  * 22: EINVAL
29
29
  * 69: EX_UNAVAILABLE (sysexits.h)
30
+ * 77: EX_NOPERM (sysexits.h): refused by policy, such as an email attachment
31
+ btx_lib_mail's security checks reject
30
32
  * 78: EX_CONFIG (sysexits.h)
31
33
  * 110: ETIMEDOUT
32
34
  * 128+N: signal N (informational only)
@@ -44,6 +46,7 @@ class ExitCode(IntEnum):
44
46
  PERMISSION_DENIED = 13
45
47
  INVALID_ARGUMENT = 22
46
48
  SMTP_FAILURE = 69
49
+ ATTACHMENT_REFUSED = 77
47
50
  CONFIG_ERROR = 78
48
51
  TIMEOUT = 110
49
52
  SIGNAL_INT = 130
@@ -235,5 +235,6 @@ allow_symlinks = false
235
235
  # Default: true (raise AttachmentSecurityError)
236
236
  # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__ATTACHMENTS__RAISE_ON_SECURITY_VIOLATION=true
237
237
  # .env: EMAIL__ATTACHMENTS__RAISE_ON_SECURITY_VIOLATION=true
238
- # Note: When false, violations log a warning and skip the attachment
238
+ # Note: When true, send-email refuses the whole message and exits 77 (EX_NOPERM)
239
+ # naming the violation; when false, violations log a warning and skip the attachment
239
240
  raise_on_security_violation = true
@@ -128,6 +128,10 @@ def send_email(
128
128
  ConfigurationError: No SMTP hosts configured.
129
129
  FileNotFoundError: Required attachment missing or unreadable and
130
130
  config.raise_on_missing_attachments is True.
131
+ AttachmentSecurityError: btx_lib_mail's security checks refused an
132
+ attachment (blocked extension or directory, symlink, size, ...)
133
+ and config.attachment_raise_on_security_violation is True.
134
+ Raised before anything is delivered.
131
135
  DeliveryError: All SMTP hosts failed for a recipient.
132
136
 
133
137
  Side Effects:
@@ -0,0 +1,191 @@
1
+ """An attachment btx_lib_mail refuses on security grounds exits 77, through the real ``main()``.
2
+
3
+ The refusal is btx_lib_mail's own check, run by the real ``send_email`` adapter: only the SMTP
4
+ transport is replaced, by a recorder, so a test proves both the exit code and that nothing was
5
+ delivered. ``AttachmentSecurityError`` is neither a ``ValueError`` nor a ``RuntimeError``, so
6
+ without its own branch it fell into the catch-all as exit 1, "Unexpected error".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import shutil
13
+ import tempfile
14
+ from pathlib import Path
15
+ from typing import IO, TYPE_CHECKING, Any
16
+
17
+ import pytest
18
+ from btx_lib_mail import ConfMail
19
+ from lib_layered_config import Config
20
+
21
+ from bitranox_template_py_cli.adapters.cli.exit_codes import ExitCode
22
+ from bitranox_template_py_cli.adapters.cli.main import main
23
+ from bitranox_template_py_cli.adapters.email.transport import send_email
24
+ from bitranox_template_py_cli.composition import AppServices, build_testing
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable, Iterator, Sequence
28
+
29
+ from btx_lib_mail.lib_mail import DeliveryOptions
30
+
31
+ from bitranox_template_py_cli.adapters.email.config import EmailConfig
32
+
33
+ EMAIL = {"email": {"smtp_hosts": ["smtp.test.com:587"], "from_address": "sender@test.com"}}
34
+ SEND_EMAIL = ["send-email", "--to", "recipient@test.com", "--subject", "s", "--body", "b"]
35
+ REFUSED = "Error: Attachment refused by security policy - "
36
+
37
+
38
+ class _Recording:
39
+ """A transport that records each recipient it is handed and sends nothing."""
40
+
41
+ def __init__(self) -> None:
42
+ self.recipients: list[str] = []
43
+
44
+ def deliver(self, *, host: str, sender: str, recipient: str, message: IO[bytes], delivery: DeliveryOptions) -> None:
45
+ self.recipients.append(recipient)
46
+
47
+
48
+ def _real_send_email(transport: _Recording) -> Callable[..., bool]:
49
+ """The production ``send_email`` adapter with only its SMTP transport replaced."""
50
+
51
+ def send(
52
+ *,
53
+ config: EmailConfig,
54
+ recipients: str | Sequence[str] | None = None,
55
+ subject: str,
56
+ body: str = "",
57
+ body_html: str = "",
58
+ from_address: str | None = None,
59
+ attachments: Sequence[Path] | None = None,
60
+ ) -> bool:
61
+ return send_email(
62
+ config=config,
63
+ recipients=recipients,
64
+ subject=subject,
65
+ body=body,
66
+ body_html=body_html,
67
+ from_address=from_address,
68
+ attachments=attachments,
69
+ transport=transport,
70
+ )
71
+
72
+ return send
73
+
74
+
75
+ def _services(transport: _Recording, data: dict[str, Any]) -> Callable[[], AppServices]:
76
+ config = Config(data, {})
77
+
78
+ def get_config(**_kwargs: Any) -> Config:
79
+ return config
80
+
81
+ def build() -> AppServices:
82
+ return dataclasses.replace(build_testing(), get_config=get_config, send_email=_real_send_email(transport))
83
+
84
+ return build
85
+
86
+
87
+ def _send_with(attachment: Path, transport: _Recording, data: dict[str, Any] = EMAIL) -> int:
88
+ return main([*SEND_EMAIL, "--attachment", str(attachment)], services_factory=_services(transport, data))
89
+
90
+
91
+ def _error_lines(err: str) -> list[str]:
92
+ return [line for line in err.splitlines() if line.startswith("Error:")]
93
+
94
+
95
+ def _is_blocked(directory: Path) -> bool:
96
+ resolved = directory.resolve()
97
+ return any(resolved.is_relative_to(blocked.resolve()) for blocked in ConfMail().attachment_blocked_directories)
98
+
99
+
100
+ def _a_file_in_a_blocked_directory() -> Path:
101
+ for directory in sorted(ConfMail().attachment_blocked_directories):
102
+ if not directory.is_dir():
103
+ continue
104
+ for entry in sorted(directory.iterdir()):
105
+ if entry.is_file() and not entry.is_symlink():
106
+ return entry
107
+ raise AssertionError("no regular file directly inside any default blocked directory on this OS")
108
+
109
+
110
+ @pytest.fixture
111
+ def unblocked_dir(tmp_path: Path) -> Iterator[Path]:
112
+ """A fresh directory under no default blocked directory, so only the file itself is judged.
113
+
114
+ pytest's tmp_path is under /var on macOS (/private/var/folders), which the defaults block.
115
+ """
116
+ base = next((base for base in (tmp_path, Path.home(), Path.cwd()) if not _is_blocked(base)), None)
117
+ assert base is not None, "no candidate directory is outside the default blocked directories"
118
+ directory = Path(tempfile.mkdtemp(prefix="attachment-refused-", dir=base))
119
+ yield directory
120
+ shutil.rmtree(directory, ignore_errors=True)
121
+
122
+
123
+ @pytest.mark.os_agnostic
124
+ def test_the_attachment_refused_code_is_sysexits_ex_noperm() -> None:
125
+ assert ExitCode.ATTACHMENT_REFUSED == 77
126
+
127
+
128
+ @pytest.mark.os_agnostic
129
+ def test_a_blocked_extension_exits_77_with_one_error_line_and_delivers_nothing(
130
+ capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture, unblocked_dir: Path
131
+ ) -> None:
132
+ attachment = unblocked_dir / "x.exe"
133
+ attachment.write_bytes(b"MZ")
134
+ transport = _Recording()
135
+
136
+ exit_code = _send_with(attachment, transport)
137
+
138
+ err = capsys.readouterr().err
139
+ assert exit_code == 77
140
+ assert _error_lines(err) == [f'{REFUSED}extension ".exe" is blocked: "{attachment.resolve()}"']
141
+ assert "Unexpected error" not in err
142
+ assert [record.getMessage() for record in caplog.records if record.exc_info] == []
143
+ assert transport.recipients == []
144
+
145
+
146
+ @pytest.mark.os_agnostic
147
+ def test_a_file_in_a_blocked_directory_exits_77_with_one_error_line_and_delivers_nothing(
148
+ capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture
149
+ ) -> None:
150
+ attachment = _a_file_in_a_blocked_directory()
151
+ transport = _Recording()
152
+
153
+ exit_code = _send_with(attachment, transport)
154
+
155
+ err = capsys.readouterr().err
156
+ lines = _error_lines(err)
157
+ assert exit_code == 77
158
+ assert len(lines) == 1
159
+ assert lines[0].startswith(f"{REFUSED}path under blocked directory ")
160
+ assert "Unexpected error" not in err
161
+ assert [record.getMessage() for record in caplog.records if record.exc_info] == []
162
+ assert transport.recipients == []
163
+
164
+
165
+ @pytest.mark.os_agnostic
166
+ def test_an_ordinary_attachment_is_delivered(capsys: pytest.CaptureFixture[str], unblocked_dir: Path) -> None:
167
+ """The control: the same wiring delivers a harmless file, so the 77 above is the refusal."""
168
+ attachment = unblocked_dir / "report.txt"
169
+ attachment.write_text("ok", encoding="utf-8")
170
+ transport = _Recording()
171
+
172
+ exit_code = _send_with(attachment, transport)
173
+
174
+ assert exit_code == 0, capsys.readouterr().err
175
+ assert transport.recipients == ["recipient@test.com"]
176
+
177
+
178
+ @pytest.mark.os_agnostic
179
+ def test_with_security_violations_set_to_warn_the_blocked_file_is_skipped_and_the_message_sent(
180
+ capsys: pytest.CaptureFixture[str], unblocked_dir: Path
181
+ ) -> None:
182
+ """77 belongs to the strict setting: in warn mode btx_lib_mail drops the file and sends the rest."""
183
+ attachment = unblocked_dir / "x.exe"
184
+ attachment.write_bytes(b"MZ")
185
+ transport = _Recording()
186
+ warn = {"email": {**EMAIL["email"], "attachments": {"raise_on_security_violation": False}}}
187
+
188
+ exit_code = _send_with(attachment, transport, warn)
189
+
190
+ assert exit_code == 0, capsys.readouterr().err
191
+ assert transport.recipients == ["recipient@test.com"]