tina4-python 3.13.91__tar.gz → 3.13.94__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 (173) hide show
  1. {tina4_python-3.13.91 → tina4_python-3.13.94}/PKG-INFO +2 -2
  2. {tina4_python-3.13.91 → tina4_python-3.13.94}/README.md +1 -1
  3. {tina4_python-3.13.91 → tina4_python-3.13.94}/pyproject.toml +1 -1
  4. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/CLAUDE.md +9 -2
  5. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/__init__.py +9 -1
  6. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/auth/__init__.py +68 -10
  7. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/connection.py +110 -5
  8. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/engine.py +68 -8
  9. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/messenger/__init__.py +89 -27
  10. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue_backends/kafka_backend.py +246 -63
  11. {tina4_python-3.13.91 → tina4_python-3.13.94}/.gitignore +0 -0
  12. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/ai/__init__.py +0 -0
  15. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/cache/__init__.py +0 -0
  17. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/cli/__init__.py +0 -0
  18. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/container/__init__.py +0 -0
  19. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/context/__init__.py +0 -0
  20. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/context/chunker.py +0 -0
  21. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/__init__.py +0 -0
  22. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/cache.py +0 -0
  23. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/constants.py +0 -0
  24. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/events.py +0 -0
  25. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/middleware.py +0 -0
  26. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/rate_limiter.py +0 -0
  27. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/request.py +0 -0
  28. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/response.py +0 -0
  29. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/router.py +0 -0
  30. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/core/server.py +0 -0
  31. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/crud/__init__.py +0 -0
  32. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/__init__.py +0 -0
  33. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/adapter.py +0 -0
  34. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/firebird.py +0 -0
  35. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/mssql.py +0 -0
  37. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/mysql.py +0 -0
  38. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/odbc.py +0 -0
  39. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/postgres.py +0 -0
  40. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/database/sqlite.py +0 -0
  41. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/debug/__init__.py +0 -0
  42. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/debug/error_overlay.py +0 -0
  43. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/__init__.py +0 -0
  44. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/metrics.py +0 -0
  45. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/metrics_engine.py +0 -0
  46. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/plan.py +0 -0
  47. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dev_admin/project_index.py +0 -0
  48. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/docs.py +0 -0
  49. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/docstore/__init__.py +0 -0
  50. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/dotenv/__init__.py +0 -0
  51. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/env.py +0 -0
  52. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/FROND.md +0 -0
  53. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/__init__.py +0 -0
  54. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/compiler.py +0 -0
  55. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/frond/parser.py +0 -0
  56. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/auth/meta.json +0 -0
  57. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  58. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/database/meta.json +0 -0
  59. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  60. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/error-overlay/meta.json +0 -0
  61. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  62. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/orm/meta.json +0 -0
  63. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  64. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  65. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/queue/meta.json +0 -0
  66. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  67. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/rest-api/meta.json +0 -0
  68. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  69. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/templates/meta.json +0 -0
  70. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  71. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  72. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/graphql/__init__.py +0 -0
  73. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/i18n/__init__.py +0 -0
  74. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mcp/__init__.py +0 -0
  75. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mcp/protocol.py +0 -0
  76. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mcp/tools.py +0 -0
  77. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/migration/__init__.py +0 -0
  78. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/migration/runner.py +0 -0
  79. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mqtt/__init__.py +0 -0
  80. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/mqtt/message.py +0 -0
  81. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/orm/__init__.py +0 -0
  82. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/orm/fields.py +0 -0
  83. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/orm/model.py +0 -0
  84. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/__feedback/widget.js +0 -0
  85. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/css/tina4.css +0 -0
  86. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/css/tina4.min.css +0 -0
  87. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/favicon.ico +0 -0
  88. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/images/logo.svg +0 -0
  89. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  90. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/frond.js +0 -0
  91. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/frond.min.js +0 -0
  92. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  93. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/tina4.min.js +0 -0
  94. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/js/tina4js.min.js +0 -0
  95. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/swagger/index.html +0 -0
  96. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  97. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/query_builder/__init__.py +0 -0
  98. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/__init__.py +0 -0
  99. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/job.py +0 -0
  100. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/kafka_backend.py +0 -0
  101. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/lite_backend.py +0 -0
  102. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/mongo_backend.py +0 -0
  103. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue/rabbitmq_backend.py +0 -0
  104. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue_backends/__init__.py +0 -0
  105. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue_backends/mongo_backend.py +0 -0
  106. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  107. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/__init__.py +0 -0
  108. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/Attachment.py +0 -0
  109. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/Channel.py +0 -0
  110. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/ChannelMember.py +0 -0
  111. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/Message.py +0 -0
  112. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/Workspace.py +0 -0
  113. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/models/__init__.py +0 -0
  114. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/realtime/storage.py +0 -0
  115. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/__init__.py +0 -0
  116. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  117. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_badges.scss +0 -0
  118. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  119. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_cards.scss +0 -0
  120. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_forms.scss +0 -0
  121. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_grid.scss +0 -0
  122. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_modals.scss +0 -0
  123. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_nav.scss +0 -0
  124. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_pagination.scss +0 -0
  125. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_reset.scss +0 -0
  126. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_tables.scss +0 -0
  127. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_typography.scss +0 -0
  128. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  129. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/_variables.scss +0 -0
  130. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/base.scss +0 -0
  131. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/colors.scss +0 -0
  132. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/scss/tina4css/tina4.scss +0 -0
  133. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/seeder/__init__.py +0 -0
  134. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/service/__init__.py +0 -0
  135. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session/__init__.py +0 -0
  136. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session_handlers/__init__.py +0 -0
  137. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  138. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session_handlers/redis_handler.py +0 -0
  139. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/session_handlers/valkey_handler.py +0 -0
  140. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/swagger/__init__.py +0 -0
  141. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/components/crud.twig +0 -0
  142. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  143. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  144. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/docker/python/Dockerfile +0 -0
  145. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  146. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/302.twig +0 -0
  147. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/401.twig +0 -0
  148. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/403.twig +0 -0
  149. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/404.twig +0 -0
  150. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/500.twig +0 -0
  151. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/502.twig +0 -0
  152. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/503.twig +0 -0
  153. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/errors/base.twig +0 -0
  154. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/frontend/README.md +0 -0
  155. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/templates/readme.md +0 -0
  156. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/test/__init__.py +0 -0
  157. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/test_client/__init__.py +0 -0
  158. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  161. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  162. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  163. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  164. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  165. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  166. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  167. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  168. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  169. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  170. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/validator/__init__.py +0 -0
  171. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/websocket/__init__.py +0 -0
  172. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/websocket/backplane.py +0 -0
  173. {tina4_python-3.13.91 → tina4_python-3.13.94}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.91
3
+ Version: 3.13.94
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -742,7 +742,7 @@ Run benchmarks locally: `python benchmarks/benchmark.py --python`
742
742
 
743
743
  ## Cross-Framework Parity
744
744
 
745
- Tina4 ships identical features across four languages: same architecture, same conventions, same 97 features:
745
+ Tina4 ships identical features across four languages: same architecture, same conventions, same 98 features:
746
746
 
747
747
  | | Python | PHP | Ruby | Node.js |
748
748
  |---|--------|-----|------|---------|
@@ -710,7 +710,7 @@ Run benchmarks locally: `python benchmarks/benchmark.py --python`
710
710
 
711
711
  ## Cross-Framework Parity
712
712
 
713
- Tina4 ships identical features across four languages: same architecture, same conventions, same 97 features:
713
+ Tina4 ships identical features across four languages: same architecture, same conventions, same 98 features:
714
714
 
715
715
  | | Python | PHP | Ruby | Node.js |
716
716
  |---|--------|-----|------|---------|
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.91"
3
+ version = "3.13.94"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -794,11 +794,18 @@ db.insert("users", {"name": "Alice", "email": "alice@example.com"})
794
794
  # Insert multiple
795
795
  db.insert("users", [{"name": "Bob"}, {"name": "Eve"}])
796
796
 
797
- # Update (by primary key — default "id")
797
+ # Update (the primary key in the data IS the filter introspected, not assumed)
798
798
  db.update("users", {"id": 1, "name": "Alice Updated"})
799
799
 
800
- # Delete
800
+ # Update many rows — the filter must be explicit
801
+ db.update("users", {"active": 0}, "last_login < ?", ["2025-01-01"])
802
+
803
+ # Delete (dict filter or string + params, both accepted)
801
804
  db.delete("users", {"id": 1})
805
+ db.delete("users", "age < ?", [18])
806
+
807
+ # Empty a table — explicit, never accidental
808
+ db.truncate("users")
802
809
 
803
810
  # Raw query
804
811
  result = db.fetch("SELECT * FROM users WHERE age > ?", [18])
@@ -43,7 +43,15 @@ def _resolve_version() -> str:
43
43
  pass
44
44
  except Exception:
45
45
  pass
46
- return "3.13.56"
46
+ # Floor literal, reached only when BOTH lookups above miss. That is not
47
+ # theoretical: the Docker build used to prune dist-info from the installed
48
+ # package, so path 2 raised PackageNotFoundError and the published
49
+ # tina4-python:3.13.94 image served this literal on /health -- then reading
50
+ # "3.13.56", 36 releases stale, because nothing kept it current.
51
+ #
52
+ # test_version_constant.py now asserts this literal equals the pyproject
53
+ # version, so the release bump cannot leave it behind again.
54
+ return "3.13.94"
47
55
 
48
56
 
49
57
  __version__ = _resolve_version()
@@ -196,19 +196,51 @@ def _resolve_secret(secret: str = None) -> str:
196
196
  return env_secret
197
197
 
198
198
 
199
+ # Supported JWT algorithms. HMAC only — the whole family is in hashlib, so this
200
+ # stays zero-dependency. The header's "alg" is now always the one we actually sign
201
+ # with (python#105): the digest is looked up here rather than hardcoded.
202
+ _HMAC_ALGORITHMS = {
203
+ "HS256": hashlib.sha256,
204
+ "HS384": hashlib.sha384,
205
+ "HS512": hashlib.sha512,
206
+ }
207
+
208
+ # Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without this a
209
+ # token minted on one host and validated on another a second behind is rejected
210
+ # for no real reason; RFC 7519 explicitly allows "a small leeway".
211
+ _JWT_LEEWAY_SECONDS = 60
212
+
213
+
214
+ def _resolve_algorithm(algorithm: str = None) -> str:
215
+ """Pick the JWT algorithm: explicit arg, else TINA4_JWT_ALGORITHM, else HS256.
216
+
217
+ Raises ValueError naming the supported set when asked for one we cannot sign
218
+ (python#106 — the env var was registered in the CLI's known_vars and then
219
+ silently ignored, so a user could set HS512 and still get HS256 tokens).
220
+ """
221
+ chosen = (algorithm or os.environ.get("TINA4_JWT_ALGORITHM") or "HS256").strip()
222
+ if chosen not in _HMAC_ALGORITHMS:
223
+ raise ValueError(
224
+ f"Unsupported JWT algorithm {chosen!r}. Tina4 signs with "
225
+ f"{', '.join(sorted(_HMAC_ALGORITHMS))} (HMAC only, zero-dependency). "
226
+ f"Set TINA4_JWT_ALGORITHM to one of those."
227
+ )
228
+ return chosen
229
+
230
+
199
231
  class Auth:
200
232
  """JWT authentication and password hashing — zero dependencies."""
201
233
 
202
- def __init__(self, secret: str = None, algorithm: str = "HS256",
234
+ def __init__(self, secret: str = None, algorithm: str = None,
203
235
  expires_in: int = None):
204
236
  """
205
237
  Args:
206
238
  secret: Signing secret (falls back to the TINA4_SECRET env var).
207
- algorithm: JWT algorithm (default HS256).
239
+ algorithm: JWT algorithm (falls back to TINA4_JWT_ALGORITHM, then HS256).
208
240
  expires_in: Token lifetime in seconds (default 3600).
209
241
  """
210
242
  self.secret = secret if secret is not None else _resolve_secret()
211
- self.algorithm = algorithm
243
+ self.algorithm = _resolve_algorithm(algorithm)
212
244
  # JWT expiry env var:
213
245
  # - TINA4_TOKEN_EXPIRES_IN (preferred — matches docs and the
214
246
  # form-token expiry env var used by Frond)
@@ -275,13 +307,29 @@ class Auth:
275
307
  return None
276
308
 
277
309
  h, p, sig = parts
310
+
311
+ # Pin the algorithm to OUR configured one instead of trusting the
312
+ # token's header. A token asking to be verified as anything else --
313
+ # "none", a weaker HMAC, or an RSA alg we do not implement -- is
314
+ # rejected before any signature work. Matches the Ruby check.
315
+ header = json.loads(_b64url_decode(h))
316
+ if header.get("alg") != self.algorithm:
317
+ return None
318
+
278
319
  expected = self._sign(f"{h}.{p}")
279
320
  if not hmac.compare_digest(sig, expected):
280
321
  return None
281
322
 
282
323
  payload = json.loads(_b64url_decode(p))
283
324
 
284
- if "exp" in payload and time.time() > payload["exp"]:
325
+ now = time.time()
326
+ if "exp" in payload and now > payload["exp"]:
327
+ return None
328
+
329
+ # "nbf" (not-before): a post-dated token is not valid yet. Was
330
+ # honoured only by Ruby, so Python/PHP/Node accepted tokens their
331
+ # issuer had explicitly marked as not-yet-usable (python#107).
332
+ if "nbf" in payload and now + _JWT_LEEWAY_SECONDS < payload["nbf"]:
285
333
  return None
286
334
 
287
335
  return payload
@@ -314,8 +362,10 @@ class Auth:
314
362
  return self.get_token(payload, expires_in=expires_in)
315
363
 
316
364
  def _sign(self, message: str) -> str:
365
+ # Digest comes from the configured algorithm, so the "alg" we advertise in
366
+ # the header is the one that actually produced this signature.
317
367
  sig = hmac.new(
318
- self.secret.encode(), message.encode(), hashlib.sha256
368
+ self.secret.encode(), message.encode(), _HMAC_ALGORITHMS[self.algorithm]
319
369
  ).digest()
320
370
  return _b64url_encode(sig)
321
371
 
@@ -424,16 +474,24 @@ class Auth:
424
474
  # ── Request Auth Helper ───────────────────────────────────────
425
475
 
426
476
  @_DualMethod
427
- def authenticate_request(self, headers: dict, secret: str = None, algorithm: str = "HS256") -> dict | None:
477
+ def authenticate_request(self, headers: dict, secret: str = None, algorithm: str = None) -> dict | None:
428
478
  """Extract and validate auth from request headers.
429
479
 
430
480
  Args:
431
481
  secret: Override signing secret (default: self.secret / TINA4_SECRET env var).
432
- algorithm: JWT algorithm override (default: "HS256").
482
+ algorithm: JWT algorithm override (default: self.algorithm / TINA4_JWT_ALGORITHM).
433
483
 
434
484
  Checks: Bearer JWT, Bearer API key, Basic auth.
435
485
  Returns payload dict on success, None on failure.
436
486
  """
487
+ # Both overrides used to be accepted and then dropped on the floor: the
488
+ # body called self.valid_token(), so a caller passing secret= or
489
+ # algorithm= silently got this instance's values instead. Honour them.
490
+ if secret is not None or algorithm is not None:
491
+ return Auth(secret=secret if secret is not None else self.secret,
492
+ algorithm=algorithm or self.algorithm,
493
+ expires_in=self.expires_in).authenticate_request(headers)
494
+
437
495
  auth_header = headers.get("authorization", "")
438
496
 
439
497
  if auth_header.startswith("Bearer "):
@@ -497,10 +555,10 @@ def refresh_token(token: str, expires_in: int = 60) -> str | None:
497
555
  return Auth.refresh_token_static(token, expires_in=expires_in)
498
556
 
499
557
 
500
- def authenticate_request(headers: dict, secret: str = None, algorithm: str = "HS256") -> dict | None:
558
+ def authenticate_request(headers: dict, secret: str = None, algorithm: str = None) -> dict | None:
501
559
  """Validate auth from request headers — reads TINA4_SECRET from env."""
502
- if secret is not None:
503
- return Auth(secret=secret).authenticate_request(headers, algorithm=algorithm)
560
+ if secret is not None or algorithm is not None:
561
+ return Auth(secret=secret, algorithm=algorithm).authenticate_request(headers)
504
562
  return Auth.authenticate_request_static(headers)
505
563
 
506
564
 
@@ -188,6 +188,7 @@ class Database:
188
188
  self._connect_kwargs = kwargs # Extra kwargs passed through to adapter.connect()
189
189
  self.last_error = None # Last execute() error message
190
190
  self._last_id = None # Last insert ID from execute/insert
191
+ self._pk_cache = {} # table -> primary-key column name (or None)
191
192
 
192
193
  if self.pool_size > 0:
193
194
  # Pooled mode — create a ConnectionPool with lazy adapter creation
@@ -681,19 +682,123 @@ class Database:
681
682
  self._last_id = result.last_id
682
683
  return result
683
684
 
685
+ def primary_key(self, table: str) -> list[str]:
686
+ """The table's primary-key columns, introspected once and cached.
687
+
688
+ Returns a LIST because a primary key may span several columns. A
689
+ composite key is still one primary key; it just has more than one
690
+ column. Returns ``[]`` when the table has no primary key or cannot be
691
+ introspected.
692
+
693
+ Uses the cross-engine ``get_columns()`` contract (v3.13.14, #48), which
694
+ reports ``primary_key`` per column on every adapter.
695
+ """
696
+ if table not in self._pk_cache:
697
+ try:
698
+ columns = self._get_adapter().get_columns(table)
699
+ self._pk_cache[table] = [
700
+ c["name"] for c in columns if c.get("primary_key")
701
+ ]
702
+ except Exception: # noqa: BLE001 - a missing table is not an error here
703
+ self._pk_cache[table] = []
704
+ return self._pk_cache[table]
705
+
706
+ def _as_where(self, filter_sql, params):
707
+ """Normalise a filter to ``(sql, params)``, accepting a dict or a string.
708
+
709
+ The declared type on ``delete`` has always been ``str | dict | list``, but
710
+ only the base adapter honoured the dict and every engine overrides it, so a
711
+ dict landed in the SQL string verbatim. Normalising here makes the declared
712
+ type true for every engine at once.
713
+ """
714
+ if isinstance(filter_sql, dict):
715
+ if not filter_sql:
716
+ return "", []
717
+ where = " AND ".join(
718
+ f"{self.quote_identifier(k)} = ?" for k in filter_sql
719
+ )
720
+ return where, list(filter_sql.values())
721
+ return filter_sql, params or []
722
+
684
723
  def update(self, table: str, data: dict,
685
- filter_sql: str = "", params: list = None) -> DatabaseResult:
724
+ filter_sql: str | dict = "", params: list = None) -> DatabaseResult:
725
+ """Update rows. A write with no filter is an error, not a full-table write.
726
+
727
+ With no explicit filter, the primary key is taken out of ``data`` and used
728
+ as the WHERE clause. With neither a filter nor a primary key in ``data``,
729
+ this raises rather than overwriting every row (audit feature 4, P1).
730
+ """
731
+ filter_sql, params = self._as_where(filter_sql, params)
732
+
733
+ if not filter_sql:
734
+ pk_columns = self.primary_key(table)
735
+ missing = [c for c in pk_columns if c not in data]
736
+ if not pk_columns or missing:
737
+ raise ValueError(
738
+ f"update requires a filter or the complete primary key in the "
739
+ f"data; pass filter explicitly to update multiple rows "
740
+ f"(table={table!r}, primary key={pk_columns!r}, "
741
+ f"missing from data={missing!r}). "
742
+ f"To empty a table use truncate({table!r})."
743
+ )
744
+ # Every primary-key column becomes part of the WHERE clause. A
745
+ # composite key that used only its first column would match every
746
+ # row sharing that value - the data-loss bug this method exists to
747
+ # prevent, reintroduced.
748
+ data = dict(data)
749
+ params = [data.pop(c) for c in pk_columns]
750
+ if not data:
751
+ raise ValueError(
752
+ f"update was given only the primary key {pk_columns!r} and no "
753
+ f"columns to set (table={table!r})"
754
+ )
755
+ filter_sql = " AND ".join(
756
+ f"{self.quote_identifier(c)} = ?" for c in pk_columns
757
+ )
758
+
686
759
  if self._cache_enabled:
687
760
  self._cache_invalidate()
688
- adapter = self._get_adapter()
689
- return adapter.update(table, data, filter_sql, params)
761
+ result = self._get_adapter().update(table, data, filter_sql, params)
762
+ return self._without_last_id(result)
690
763
 
691
764
  def delete(self, table: str,
692
765
  filter_sql: str | dict | list = "", params: list = None) -> DatabaseResult:
766
+ """Delete rows. A filterless delete raises; use ``truncate()`` to empty."""
767
+ if isinstance(filter_sql, list):
768
+ total = 0
769
+ for row_filter in filter_sql:
770
+ total += self.delete(table, row_filter).affected_rows
771
+ return DatabaseResult(affected_rows=total)
772
+
773
+ filter_sql, params = self._as_where(filter_sql, params)
774
+ if not filter_sql:
775
+ raise ValueError(
776
+ f"delete requires a filter (table={table!r}). "
777
+ f"To remove every row use truncate({table!r})."
778
+ )
779
+
693
780
  if self._cache_enabled:
694
781
  self._cache_invalidate()
695
- adapter = self._get_adapter()
696
- return adapter.delete(table, filter_sql, params)
782
+ result = self._get_adapter().delete(table, filter_sql, params)
783
+ return self._without_last_id(result)
784
+
785
+ def truncate(self, table: str) -> DatabaseResult:
786
+ """Remove every row. The explicit spelling of a whole-table delete."""
787
+ if self._cache_enabled:
788
+ self._cache_invalidate()
789
+ result = self._get_adapter().delete(table, "1 = 1", [])
790
+ return self._without_last_id(result)
791
+
792
+ @staticmethod
793
+ def _without_last_id(result: DatabaseResult) -> DatabaseResult:
794
+ """``last_id`` is insert-only, per the documented contract.
795
+
796
+ PHP already does this via ``writeResult($adapter, withLastId: false)``;
797
+ Python reported the connection's last insert id on an UPDATE.
798
+ """
799
+ if result is not None and getattr(result, "last_id", None) is not None:
800
+ result.last_id = None
801
+ return result
697
802
 
698
803
  def start_transaction(self):
699
804
  """Begin a transaction. Pins the adapter to this thread for the
@@ -1626,11 +1626,57 @@ class Frond:
1626
1626
  )
1627
1627
  """
1628
1628
  self._sandbox = True
1629
- self._allowed_filters = set(allowed_filters) if allowed_filters else None
1630
- self._allowed_tags = set(allowed_tags) if allowed_tags else None
1631
- self._allowed_vars = set(allowed_vars) if allowed_vars else None
1629
+ # `is not None`, NOT truthiness: an EMPTY list means "permit nothing",
1630
+ # while None means "permit everything". Testing truthiness collapsed the
1631
+ # two, so a caller that computed an allow-list and got an empty result
1632
+ # silently opened the sandbox instead of closing it.
1633
+ self._allowed_filters = set(allowed_filters) if allowed_filters is not None else None
1634
+ self._allowed_tags = set(allowed_tags) if allowed_tags is not None else None
1635
+ self._allowed_vars = set(allowed_vars) if allowed_vars is not None else None
1632
1636
  return self
1633
1637
 
1638
+ def _filter_permitted(self, fname: str) -> bool:
1639
+ """Is this filter permitted to run under the current sandbox?
1640
+
1641
+ The single source of truth for "may this filter run", so the escaping
1642
+ decision and the filter loop can never disagree.
1643
+ """
1644
+ if not self._sandbox or self._allowed_filters is None:
1645
+ return True
1646
+ return fname in self._allowed_filters
1647
+
1648
+ #: Node kinds that are TAGS a template author writes, mapped to the name
1649
+ #: they type. Kinds absent from this map are structure (text, output,
1650
+ #: comment, extends/block markers) and are never gated - blocking those
1651
+ #: would stop the template rendering at all rather than restrict it.
1652
+ _GATEABLE_TAGS = {
1653
+ "if": "if",
1654
+ "for": "for",
1655
+ "set": "set",
1656
+ "set_block": "set",
1657
+ "include": "include",
1658
+ "macro": "macro",
1659
+ "from_import": "from",
1660
+ "import_as": "import",
1661
+ "cache": "cache",
1662
+ "spaceless": "spaceless",
1663
+ "autoescape": "autoescape",
1664
+ "live": "live",
1665
+ }
1666
+
1667
+ def _tag_permitted(self, kind: str) -> bool:
1668
+ """Is this tag permitted under the current sandbox?
1669
+
1670
+ One gate for every tag, so the allow-list governs the whole tag
1671
+ vocabulary instead of whichever names somebody remembered to check.
1672
+ """
1673
+ if not self._sandbox or self._allowed_tags is None:
1674
+ return True
1675
+ tag = self._GATEABLE_TAGS.get(kind)
1676
+ if tag is None:
1677
+ return True # structural node, not an author-written tag
1678
+ return tag in self._allowed_tags
1679
+
1634
1680
  def unsandbox(self):
1635
1681
  """Disable sandbox mode."""
1636
1682
  self._sandbox = False
@@ -2017,6 +2063,14 @@ class Frond:
2017
2063
  if node.strip_before and output:
2018
2064
  output[-1] = output[-1].rstrip()
2019
2065
 
2066
+ # Sandbox: ONE tag gate, here at dispatch, so every tag is gated by
2067
+ # construction and a tag added later is gated the day it is added.
2068
+ # This replaced a single per-name conditional that covered `include`
2069
+ # only, which let `{% autoescape false %}` turn escaping OFF from
2070
+ # inside a sandbox that had not allowed it (audit feature 38, P1b).
2071
+ if not self._tag_permitted(kind):
2072
+ continue
2073
+
2020
2074
  if kind == "output":
2021
2075
  result = self._eval_var(node.expr, context)
2022
2076
  output.append(self._to_output(result))
@@ -2042,10 +2096,9 @@ class Frond:
2042
2096
  )
2043
2097
 
2044
2098
  elif kind == "include":
2045
- # Sandbox: check tag
2046
- if not (self._sandbox and self._allowed_tags is not None
2047
- and "include" not in self._allowed_tags):
2048
- output.append(self._handle_include(node.content, context))
2099
+ # The sandbox tag gate now runs once at dispatch (above), so the
2100
+ # per-name conditional that used to live here is gone.
2101
+ output.append(self._handle_include(node.content, context))
2049
2102
 
2050
2103
  elif kind == "macro":
2051
2104
  self._handle_macro(node, context)
@@ -2275,7 +2328,14 @@ class Frond:
2275
2328
  # the var_name eval lets a parenthesised pipe like `{{ (a|f) }}` resolve
2276
2329
  # too — the old code returned empty for that.
2277
2330
  value = _eval_expr(var_name, context, self._apply_filters)
2278
- is_safe = any(name in ("raw", "safe") for name, _ in filters)
2331
+ # `raw`/`safe` suppress escaping only if the sandbox PERMITS them to run.
2332
+ # Reading the filter name straight out of the parsed source meant a
2333
+ # sandbox that DENIED raw still marked the value safe, so denying raw
2334
+ # produced byte-identical output to allowing it (audit feature 38, P1).
2335
+ is_safe = any(
2336
+ name in ("raw", "safe") and self._filter_permitted(name)
2337
+ for name, _ in filters
2338
+ )
2279
2339
  value = self._apply_filters(value, filters, context)
2280
2340
 
2281
2341
  # Auto-escape HTML unless marked safe or SafeString
@@ -106,6 +106,11 @@ class Messenger:
106
106
  encryption: str = None, use_tls: bool = None,
107
107
  imap_host: str = None, imap_port: int = None):
108
108
  # SMTP (send) — priority: constructor > .env > sensible default
109
+ # Whether a host was actually CONFIGURED, which is not the same as
110
+ # self.host being set: self.host falls back to "localhost", so it is never
111
+ # empty and cannot answer "can this messenger send?". The dev-capture gate
112
+ # needs that answer, so record it here while the real inputs are in scope.
113
+ self._smtp_configured = bool(host or os.environ.get("TINA4_MAIL_HOST"))
109
114
  self.host = host or os.environ.get("TINA4_MAIL_HOST", "localhost")
110
115
  self.port = port or int(os.environ.get("TINA4_MAIL_PORT", "587"))
111
116
  self.username = username or os.environ.get("TINA4_MAIL_USERNAME", "")
@@ -141,6 +146,37 @@ class Messenger:
141
146
  """Add a default header to all outgoing emails."""
142
147
  self._default_headers[name] = value
143
148
 
149
+ def _should_capture(self) -> bool:
150
+ """Should send() capture to a local mailbox instead of talking to SMTP?
151
+
152
+ Availability decides, not verbosity. With no SMTP host configured sending
153
+ is impossible, so simulate it into a folder rather than failing -- that is
154
+ what makes a laptop with no mail server usable. ``TINA4_MAIL_CAPTURE``
155
+ forces capture even when a host IS configured, for anyone who wants
156
+ "never send real mail from this box".
157
+
158
+ ``TINA4_DEBUG`` deliberately does NOT gate this. Debug must still be able
159
+ to send: tying capture to it means nobody can test a real send from a dev
160
+ box, which is the common case.
161
+ """
162
+ from tina4_python.dotenv import is_truthy
163
+ if is_truthy(os.environ.get("TINA4_MAIL_CAPTURE", "")):
164
+ return True
165
+ return not self._smtp_configured
166
+
167
+ def _dev_mailbox(self) -> "DevMailbox":
168
+ """The local mailbox, created on first capture and reused after.
169
+
170
+ Attached lazily so a messenger that never captures never grows the
171
+ attribute -- callers test ``hasattr(messenger, "dev_mailbox")`` to ask
172
+ "is this a capturing messenger?".
173
+ """
174
+ mailbox = getattr(self, "dev_mailbox", None)
175
+ if mailbox is None:
176
+ mailbox = DevMailbox()
177
+ self.dev_mailbox = mailbox
178
+ return mailbox
179
+
144
180
  def send(self, to: str | list[str], subject: str, body: str,
145
181
  html: bool = False, text: str = None,
146
182
  cc: str | list[str] = None,
@@ -167,6 +203,21 @@ class Messenger:
167
203
  cc_list = [cc] if isinstance(cc, str) else list(cc or [])
168
204
  bcc_list = [bcc] if isinstance(bcc, str) else list(bcc or [])
169
205
 
206
+ # Dev capture is a BRANCH here, not a method swapped onto the instance.
207
+ # The swap was the cause of tina4-nodejs#42 in Python: dev_send declared
208
+ # (to, subject, body, html, cc, ...) while this method declares
209
+ # (to, subject, body, html, text, cc, ...), so one name meant two
210
+ # signatures -- send(to, subj, body, True, "plain text") filed the
211
+ # plain-text body as a CC RECIPIENT and reported success, and
212
+ # send(text=...) raised TypeError on a dev messenger.
213
+ if self._should_capture():
214
+ return self._dev_mailbox().capture(
215
+ to, subject, body, html, text,
216
+ cc=cc_list, bcc=bcc_list, reply_to=reply_to,
217
+ from_address=self.from_address, from_name=self.from_name,
218
+ attachments=attachments,
219
+ )
220
+
170
221
  has_attachments = bool(attachments)
171
222
  has_text_alt = text is not None and html
172
223
 
@@ -728,24 +779,44 @@ class DevMailbox:
728
779
  self._inbox_dir.mkdir(exist_ok=True)
729
780
 
730
781
  def capture(self, to: str | list[str], subject: str, body: str,
731
- html: bool = False, cc: list[str] = None,
732
- bcc: list[str] = None, reply_to: str = None,
782
+ html: bool = False, text: str = None,
783
+ cc: str | list[str] = None,
784
+ bcc: str | list[str] = None, reply_to: str = None,
733
785
  from_address: str = "", from_name: str = "",
734
786
  attachments: list = None) -> dict:
735
- """Capture a message to the local outbox (instead of sending via SMTP)."""
787
+ """Capture a message to the local outbox (instead of sending via SMTP).
788
+
789
+ The parameter order MATCHES ``Messenger.send`` on purpose. It did not
790
+ before: send's 5th positional was ``text`` and capture's was ``cc``, so the
791
+ same call meant different things depending on which door it came through --
792
+ that mismatch IS tina4-nodejs#42.
793
+
794
+ BREAKING: ``text`` is now the 5th positional. A caller passing ``cc``
795
+ positionally must switch to the keyword form. Aligning the two signatures
796
+ is the fix; leaving them apart would preserve the bug.
797
+
798
+ cc/bcc are normalised HERE, at the boundary, so a message is well formed
799
+ however it arrived. Normalising in one caller only (which is what Python
800
+ did -- in dev_send, never in capture) means a direct caller stores a
801
+ malformed message and is told it succeeded, and a dev mailbox that accepts
802
+ a broken message defeats its own purpose.
803
+ """
736
804
  msg_id = f"{int(time.time() * 1000)}_{id(subject) & 0xFFFF:04x}"
737
805
  to_list = [to] if isinstance(to, str) else list(to)
806
+ cc_list = [cc] if isinstance(cc, str) else list(cc or [])
807
+ bcc_list = [bcc] if isinstance(bcc, str) else list(bcc or [])
738
808
 
739
809
  message = {
740
810
  "id": msg_id,
741
811
  "type": "outbox",
742
812
  "from": f"{from_name} <{from_address}>" if from_name else from_address,
743
813
  "to": to_list,
744
- "cc": cc or [],
745
- "bcc": bcc or [],
814
+ "cc": cc_list,
815
+ "bcc": bcc_list,
746
816
  "reply_to": reply_to or "",
747
817
  "subject": subject,
748
818
  "body": body,
819
+ "text": text,
749
820
  "html": html,
750
821
  "attachments": [
751
822
  a if isinstance(a, str) else a.get("filename", "attachment")
@@ -909,31 +980,22 @@ def _is_dev_mode() -> bool:
909
980
  def create_messenger(**kwargs) -> Messenger:
910
981
  """Factory that returns a Messenger configured for the current environment.
911
982
 
912
- In dev mode (TINA4_DEBUG=true), email sending is intercepted
913
- by DevMailbox no SMTP connection needed. The original Messenger.send()
914
- is replaced with a local capture.
983
+ Returns ONE concrete type, always. When sending is impossible (no SMTP host
984
+ configured) or suppressed (``TINA4_MAIL_CAPTURE``), ``send()`` captures to a
985
+ local ``DevMailbox`` instead -- decided by a branch inside ``send()``, so the
986
+ object you get back has one ``send`` with one signature either way.
987
+
988
+ It no longer replaces ``send`` on the instance. That swap installed a function
989
+ with a DIFFERENT signature than ``Messenger.send`` under the same name, which
990
+ is how the documented call ``send(to, subj, body, True, "plain text")`` came to
991
+ file the plain-text body as a CC recipient and report success.
915
992
  """
916
993
  messenger = Messenger(**kwargs)
917
994
 
918
- if _is_dev_mode():
919
- mailbox = DevMailbox()
920
- # Monkey-patch send to capture locally
921
- _original_send = messenger.send
922
-
923
- def dev_send(to, subject, body, html=False, cc=None, bcc=None,
924
- reply_to=None, attachments=None, headers=None):
925
- return mailbox.capture(
926
- to=to, subject=subject, body=body, html=html,
927
- cc=[cc] if isinstance(cc, str) else (cc or []),
928
- bcc=[bcc] if isinstance(bcc, str) else (bcc or []),
929
- reply_to=reply_to,
930
- from_address=messenger.from_address,
931
- from_name=messenger.from_name,
932
- attachments=attachments,
933
- )
934
-
935
- messenger.send = dev_send
936
- messenger.dev_mailbox = mailbox
995
+ # Attach the mailbox eagerly when this messenger will capture, so callers can
996
+ # inspect it (and the dev admin panel can list it) before the first send.
997
+ if messenger._should_capture():
998
+ messenger.dev_mailbox = DevMailbox()
937
999
 
938
1000
  return messenger
939
1001