velocity-python 0.1.95__tar.gz → 0.1.98__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 (263) hide show
  1. {velocity_python-0.1.95 → velocity_python-0.1.98}/PKG-INFO +2 -2
  2. {velocity_python-0.1.95 → velocity_python-0.1.98}/README.md +1 -1
  3. {velocity_python-0.1.95 → velocity_python-0.1.98}/pyproject.toml +1 -1
  4. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/__init__.py +1 -1
  5. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/mixins/web_handler.py +17 -3
  6. velocity_python-0.1.98/src/velocity/misc/mail.py +453 -0
  7. velocity_python-0.1.98/src/velocity/misc/tests/test_mail_send.py +365 -0
  8. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/PKG-INFO +2 -2
  9. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/SOURCES.txt +1 -0
  10. velocity_python-0.1.95/src/velocity/misc/mail.py +0 -86
  11. {velocity_python-0.1.95 → velocity_python-0.1.98}/LICENSE +0 -0
  12. {velocity_python-0.1.95 → velocity_python-0.1.98}/setup.cfg +0 -0
  13. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/__init__.py +0 -0
  14. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/amplify.py +0 -0
  15. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/amplify_build.py +0 -0
  16. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/__init__.py +0 -0
  17. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/backfill.py +0 -0
  18. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/indexing.py +0 -0
  19. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/references.py +0 -0
  20. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/service.py +0 -0
  21. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/usage_index.py +0 -0
  22. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/__init__.py +0 -0
  23. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/config.py +0 -0
  24. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/operations.py +0 -0
  25. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/policies.py +0 -0
  26. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/reconcile.py +0 -0
  27. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/targets.py +0 -0
  28. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/dirty_pipeline.py +0 -0
  29. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/__init__.py +0 -0
  30. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/base_handler.py +0 -0
  31. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/context.py +0 -0
  32. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/context_factory.py +0 -0
  33. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/exceptions.py +0 -0
  34. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  35. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/masquerade.py +0 -0
  36. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
  37. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
  38. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/mixins/job_handler.py +0 -0
  39. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/perf.py +0 -0
  40. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/response.py +0 -0
  41. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  42. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/job_results.py +0 -0
  43. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/s3.py +0 -0
  44. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/sms.py +0 -0
  45. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/ssm_config.py +0 -0
  46. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/__init__.py +0 -0
  47. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_amplify_build_policy_size.py +0 -0
  48. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_amplify_build_shim.py +0 -0
  49. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
  50. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_deploy_policies.py +0 -0
  51. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_deploy_reconcile.py +0 -0
  52. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
  53. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_response.py +0 -0
  54. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_sms.py +0 -0
  55. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/__init__.py +0 -0
  56. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/__init__.py +0 -0
  57. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/async_support.py +0 -0
  58. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/column.py +0 -0
  59. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/database.py +0 -0
  60. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/decorators.py +0 -0
  61. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/engine.py +0 -0
  62. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/jsonproxy.py +0 -0
  63. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/result.py +0 -0
  64. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/row.py +0 -0
  65. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/sequence.py +0 -0
  66. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/table.py +0 -0
  67. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/transaction.py +0 -0
  68. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/view.py +0 -0
  69. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/exceptions.py +0 -0
  70. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/maintenance.py +0 -0
  71. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/migrations.py +0 -0
  72. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/__init__.py +0 -0
  73. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/__init__.py +0 -0
  74. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/initializer.py +0 -0
  75. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/operators.py +0 -0
  76. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/sql.py +0 -0
  77. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/types.py +0 -0
  78. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/__init__.py +0 -0
  79. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/operators.py +0 -0
  80. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/reserved.py +0 -0
  81. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/sql.py +0 -0
  82. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/types.py +0 -0
  83. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/__init__.py +0 -0
  84. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/operators.py +0 -0
  85. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/reserved.py +0 -0
  86. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/sql.py +0 -0
  87. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/types.py +0 -0
  88. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/__init__.py +0 -0
  89. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/operators.py +0 -0
  90. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/reserved.py +0 -0
  91. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/sql.py +0 -0
  92. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/types.py +0 -0
  93. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
  94. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/operators.py +0 -0
  95. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
  96. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/sql.py +0 -0
  97. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/types.py +0 -0
  98. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/tablehelper.py +0 -0
  99. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/__init__.py +0 -0
  100. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/common_db_test.py +0 -0
  101. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/__init__.py +0 -0
  102. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/common.py +0 -0
  103. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/conftest.py +0 -0
  104. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_column.py +0 -0
  105. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_connections.py +0 -0
  106. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_database.py +0 -0
  107. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_engine.py +0 -0
  108. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
  109. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_imports.py +0 -0
  110. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_result.py +0 -0
  111. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_row.py +0 -0
  112. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
  113. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
  114. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
  115. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
  116. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
  117. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_table.py +0 -0
  118. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
  119. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
  120. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/sql/__init__.py +0 -0
  121. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/sql/common.py +0 -0
  122. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
  123. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
  124. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
  125. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_db_utils.py +0 -0
  126. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_postgres.py +0 -0
  127. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
  128. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
  129. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_result_caching.py +0 -0
  130. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
  131. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
  132. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
  133. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
  134. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_sql_builder.py +0 -0
  135. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_tablehelper.py +0 -0
  136. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_view_helper.py +0 -0
  137. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/utils.py +0 -0
  138. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/logging.py +0 -0
  139. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/__init__.py +0 -0
  140. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/conv/__init__.py +0 -0
  141. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/conv/iconv.py +0 -0
  142. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/conv/oconv.py +0 -0
  143. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/db.py +0 -0
  144. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/export.py +0 -0
  145. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/format.py +0 -0
  146. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/merge.py +0 -0
  147. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/pdf.py +0 -0
  148. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/__init__.py +0 -0
  149. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_db.py +0 -0
  150. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_fix.py +0 -0
  151. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_format.py +0 -0
  152. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_iconv.py +0 -0
  153. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_merge.py +0 -0
  154. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_oconv.py +0 -0
  155. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_original_error.py +0 -0
  156. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_timer.py +0 -0
  157. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/timer.py +0 -0
  158. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tools.py +0 -0
  159. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/__init__.py +0 -0
  160. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/core/__init__.py +0 -0
  161. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/core/capabilities.py +0 -0
  162. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/core/store.py +0 -0
  163. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/core/where.py +0 -0
  164. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/exceptions.py +0 -0
  165. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/__init__.py +0 -0
  166. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/base/__init__.py +0 -0
  167. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/cassandra/__init__.py +0 -0
  168. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/cassandra/translation.py +0 -0
  169. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/couchbase/__init__.py +0 -0
  170. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/couchbase/translation.py +0 -0
  171. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/dynamodb/__init__.py +0 -0
  172. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/dynamodb/translation.py +0 -0
  173. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/elasticsearch/__init__.py +0 -0
  174. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/elasticsearch/translation.py +0 -0
  175. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/memory/__init__.py +0 -0
  176. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/mongodb/__init__.py +0 -0
  177. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/mongodb/translation.py +0 -0
  178. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/redis/__init__.py +0 -0
  179. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/__init__.py +0 -0
  180. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/authorizenet_adapter.py +0 -0
  181. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/authorizenet_mirror.py +0 -0
  182. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/base_adapter.py +0 -0
  183. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/braintree_adapter.py +0 -0
  184. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/braintree_mirror.py +0 -0
  185. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/charge_rules.py +0 -0
  186. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/stripe_adapter.py +0 -0
  187. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/stripe_mirror.py +0 -0
  188. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  189. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/entry_points.txt +0 -0
  190. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/requires.txt +0 -0
  191. {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/top_level.txt +0 -0
  192. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_activity_tracking_aborted_tx.py +0 -0
  193. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_amplify_build.py +0 -0
  194. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_asset_indexing.py +0 -0
  195. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_asset_references.py +0 -0
  196. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_assets_service.py +0 -0
  197. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_async_support.py +0 -0
  198. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_batch_operations.py +0 -0
  199. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_column_tx_arg.py +0 -0
  200. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_concurrency_safety.py +0 -0
  201. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_connection_pool.py +0 -0
  202. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_connection_resilience.py +0 -0
  203. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_context_job_descriptions.py +0 -0
  204. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_db_credentials_ssm_cascade.py +0 -0
  205. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_db_maintenance.py +0 -0
  206. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_decorators.py +0 -0
  207. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_delete_hook_fk_blocked.py +0 -0
  208. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_dirty_pipeline_fast_path.py +0 -0
  209. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_email_processing.py +0 -0
  210. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_enqueue_send_failures.py +0 -0
  211. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_get_cognito_user_provider.py +0 -0
  212. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_http_handler_rollback.py +0 -0
  213. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_iconv_money_to_cents.py +0 -0
  214. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_identifier_injection_guard.py +0 -0
  215. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_job_handler.py +0 -0
  216. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_job_results.py +0 -0
  217. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_json_columns.py +0 -0
  218. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_jsonb_dict_adapter.py +0 -0
  219. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_lambda_handler.py +0 -0
  220. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_lambda_handler_auth.py +0 -0
  221. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_lambda_handler_masquerade.py +0 -0
  222. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_masquerade_grant.py +0 -0
  223. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_mixins_import.py +0 -0
  224. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_n_plus_one.py +0 -0
  225. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_dynamodb.py +0 -0
  226. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_memory.py +0 -0
  227. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_mongodb.py +0 -0
  228. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_redis.py +0 -0
  229. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_translation.py +0 -0
  230. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_where.py +0 -0
  231. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_observability.py +0 -0
  232. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_on_commit.py +0 -0
  233. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_authorizenet_adapter.py +0 -0
  234. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_braintree_adapter.py +0 -0
  235. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_braintree_mirror.py +0 -0
  236. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_mirror_empty_table.py +0 -0
  237. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_profile_sorting.py +0 -0
  238. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_stripe_adapter.py +0 -0
  239. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_pdf.py +0 -0
  240. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_prepared_statements.py +0 -0
  241. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_psycopg3_upgrade.py +0 -0
  242. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_query_cache.py +0 -0
  243. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_restricted_direct_tables.py +0 -0
  244. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_retry_side_effect_guard.py +0 -0
  245. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_return_default_safety.py +0 -0
  246. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_row_batch_update.py +0 -0
  247. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_row_cache_staleness.py +0 -0
  248. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_schema_migrations.py +0 -0
  249. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_security_hardening.py +0 -0
  250. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_server_cursor.py +0 -0
  251. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_single_autocommit_safety.py +0 -0
  252. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_spreadsheet_functions.py +0 -0
  253. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_sqlite_backend.py +0 -0
  254. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_sqs_per_record_transactions.py +0 -0
  255. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_ssm_config.py +0 -0
  256. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_store_user_data.py +0 -0
  257. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_sys_modified_count_postgres_demo.py +0 -0
  258. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_table_alter.py +0 -0
  259. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_transaction_class_wrapping.py +0 -0
  260. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_transaction_commit_and_ownership.py +0 -0
  261. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_transaction_edge_cases.py +0 -0
  262. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_where_clause_validation.py +0 -0
  263. {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_write_hook_create_flow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.1.95
3
+ Version: 0.1.98
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Velocity Team <info@codeclubs.org>
6
6
  License-Expression: MIT
@@ -253,7 +253,7 @@ Full documentation is included in the `docs/` directory of the source distributi
253
253
  | AWS Lambda Handlers | `docs/aws-handlers.md` | LambdaHandler, SqsHandler, auth modes, per-record transactions, async jobs (JobHandler + job result stores) |
254
254
  | NoSQL Adapters | `docs/nosql.md` | Redis, DynamoDB, MongoDB, Elasticsearch, Cassandra, Couchbase behind one capability-honest contract |
255
255
  | Payment Processing | `docs/payment.md` | Stripe and Braintree adapters, payment lifecycle |
256
- | Utilities | `docs/utilities.md` | Excel export, data conversion, formatting, timers, email parsing |
256
+ | Utilities | `docs/utilities.md` | Excel export, data conversion, formatting, timers, email parsing and SMTP sending |
257
257
  | Testing Guide | `docs/TESTING.md` | Running tests, markers, coverage |
258
258
  | Security | `docs/SECURITY.md` | Pre-commit hooks, credential scanning |
259
259
 
@@ -159,7 +159,7 @@ Full documentation is included in the `docs/` directory of the source distributi
159
159
  | AWS Lambda Handlers | `docs/aws-handlers.md` | LambdaHandler, SqsHandler, auth modes, per-record transactions, async jobs (JobHandler + job result stores) |
160
160
  | NoSQL Adapters | `docs/nosql.md` | Redis, DynamoDB, MongoDB, Elasticsearch, Cassandra, Couchbase behind one capability-honest contract |
161
161
  | Payment Processing | `docs/payment.md` | Stripe and Braintree adapters, payment lifecycle |
162
- | Utilities | `docs/utilities.md` | Excel export, data conversion, formatting, timers, email parsing |
162
+ | Utilities | `docs/utilities.md` | Excel export, data conversion, formatting, timers, email parsing and SMTP sending |
163
163
  | Testing Guide | `docs/TESTING.md` | Running tests, markers, coverage |
164
164
  | Security | `docs/SECURITY.md` | Pre-commit hooks, credential scanning |
165
165
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "velocity-python"
7
- version = "0.1.95"
7
+ version = "0.1.98"
8
8
  authors = [
9
9
  { name="Velocity Team", email="info@codeclubs.org" },
10
10
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.1.95"
1
+ __version__ = version = "0.1.98"
2
2
 
3
3
  import importlib as _importlib
4
4
 
@@ -395,13 +395,27 @@ Request Details:
395
395
  return ""
396
396
 
397
397
  def _get_sendmail_callable(self):
398
- """Resolve notification sender callable without hard dependency."""
398
+ """Resolve notification sender callable without hard dependency.
399
+
400
+ An application-supplied ``support.app.helpers.sendmail`` wins so
401
+ existing projects keep their own delivery rules (recipient whitelists,
402
+ non-production rewriting, send logging). Projects without that package
403
+ fall back to velocity's own SMTP sender, which resolves configuration
404
+ from an ``email_settings`` row or the ``Mail*`` config keys — before
405
+ this fallback existed they silently sent nothing.
406
+ """
399
407
  try:
400
408
  from support.app import helpers
401
409
 
402
- return getattr(helpers, "sendmail", None)
410
+ app_sendmail = getattr(helpers, "sendmail", None)
411
+ if app_sendmail is not None:
412
+ return app_sendmail
403
413
  except ImportError:
404
- return None
414
+ pass
415
+
416
+ from velocity.misc.mail import sendmail
417
+
418
+ return sendmail
405
419
 
406
420
  def _get_error_context(self, context) -> str:
407
421
  """Sanitize request context for reporting"""
@@ -0,0 +1,453 @@
1
+ #!/usr/local/bin/python
2
+ from email.parser import Parser as EmailParser
3
+ from email.message import Message
4
+ from typing import Any, Dict, List, Optional, Sequence, cast
5
+ import mimetypes
6
+ import hashlib
7
+
8
+
9
+ class NotSupportedMailFormat(Exception):
10
+ """Exception raised for unsupported mail formats."""
11
+
12
+ pass
13
+
14
+
15
+ class Attachment:
16
+ """Represents an email attachment."""
17
+
18
+ def __init__(self, name: str, data: bytes):
19
+ self.name = name
20
+ self.data = data
21
+ self.ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
22
+ self.size = len(data)
23
+ self.hash = hashlib.sha1(data).hexdigest()
24
+
25
+
26
+ def get_full_emails(addresses: Sequence[Any]) -> List[str]:
27
+ """Generates a list of formatted email addresses with names."""
28
+ results = []
29
+ for a in addresses:
30
+ mailbox = a.mailbox.decode("utf-8")
31
+ host = a.host.decode("utf-8")
32
+ name = a.name.decode("utf-8") if a.name else None
33
+ if name:
34
+ results.append(f"{name} <{mailbox}@{host}>")
35
+ else:
36
+ results.append(f"{mailbox}@{host}")
37
+ return results
38
+
39
+
40
+ def get_address_only(addresses: Sequence[Any]) -> List[str]:
41
+ """Generates a list of email addresses without names."""
42
+ return [f"{a.mailbox.decode('utf-8')}@{a.host.decode('utf-8')}" for a in addresses]
43
+
44
+
45
+ def parse_attachment(part: Message) -> Optional[Attachment]:
46
+ """Parses an attachment from a message part if present."""
47
+ content_disposition = part.get("Content-Disposition")
48
+ if content_disposition:
49
+ dispositions = content_disposition.strip().split(";")
50
+ if dispositions[0].lower() == "attachment":
51
+ name = part.get_filename()
52
+ payload = part.get_payload(decode=True)
53
+ data = cast(
54
+ Optional[bytes],
55
+ payload if isinstance(payload, (bytes, bytearray)) else None,
56
+ )
57
+ if name and data is not None:
58
+ return Attachment(name=name, data=bytes(data))
59
+ return None
60
+
61
+
62
+ def parse(content: str) -> Dict[str, Any]:
63
+ """Parses the email content and extracts plain text, HTML, and attachments."""
64
+ body = bytearray()
65
+ html = bytearray()
66
+ attachments: List[Attachment] = []
67
+
68
+ message = EmailParser().parsestr(content)
69
+ for part in message.walk():
70
+ attachment = parse_attachment(part)
71
+ if attachment:
72
+ attachments.append(attachment)
73
+ elif part.get_content_type() == "text/plain":
74
+ payload = part.get_payload(decode=True)
75
+ if isinstance(payload, (bytes, bytearray)):
76
+ body.extend(payload)
77
+ elif part.get_content_type() == "text/html":
78
+ payload = part.get_payload(decode=True)
79
+ if isinstance(payload, (bytes, bytearray)):
80
+ html.extend(payload)
81
+
82
+ return {
83
+ "body": body.decode("utf-8") if body else None,
84
+ "html": html.decode("utf-8") if html else None,
85
+ "attachments": attachments,
86
+ }
87
+
88
+
89
+ # ── Outbound mail ───────────────────────────────────────────────────────────
90
+ #
91
+ # Everything above parses inbound mail. Everything below sends it.
92
+ #
93
+ # velocity had no sender: `WebHandler.send_error_notification` resolves a
94
+ # `sendmail` callable by importing an application package, so any project
95
+ # without that package silently sent nothing. `sendmail` below is a drop-in
96
+ # with the same signature, so the framework can fall back to it.
97
+ #
98
+ # Two configuration sources, because both are already in use:
99
+ # * `SmtpConfig.from_env()` — Mail* keys via velocity's ssm_config
100
+ # * `SmtpConfig.from_email_settings()` — an `email_settings` row (rot13-obscured,
101
+ # the legacy storage format)
102
+
103
+ import codecs
104
+ import smtplib
105
+ from dataclasses import dataclass, replace
106
+ from email.message import EmailMessage
107
+
108
+
109
+ class MailConfigurationError(Exception):
110
+ """Raised when SMTP configuration is absent or incomplete."""
111
+
112
+
113
+ DEFAULT_EMAIL_SETTINGS_ID = 1000
114
+
115
+
116
+ def _clean(value: Any) -> str:
117
+ return "" if value is None else str(value).strip()
118
+
119
+
120
+ def _as_list(value: Any) -> List[str]:
121
+ """Normalize None / str / iterable into a list of non-empty addresses."""
122
+ if value is None:
123
+ return []
124
+ if isinstance(value, str):
125
+ items = [value]
126
+ else:
127
+ items = list(value)
128
+ return [_clean(item) for item in items if _clean(item)]
129
+
130
+
131
+ def _rot13(value: Any) -> str:
132
+ text = _clean(value)
133
+ return codecs.decode(text, "rot13") if text else ""
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class SmtpConfig:
138
+ """Connection settings for an SMTP relay.
139
+
140
+ ``sender`` is the default From address; it can be overridden per message.
141
+ """
142
+
143
+ host: str
144
+ port: int = 587
145
+ username: Optional[str] = None
146
+ password: Optional[str] = None
147
+ sender: Optional[str] = None
148
+ use_tls: bool = True
149
+ timeout: int = 30
150
+
151
+ def __post_init__(self):
152
+ if not _clean(self.host):
153
+ raise MailConfigurationError("SmtpConfig: host is required")
154
+
155
+ @classmethod
156
+ def from_env(cls, **overrides) -> "SmtpConfig":
157
+ """Build from the ``Mail*`` config keys, resolved through ssm_config.
158
+
159
+ Reads ``MailHost``, ``MailPort``, ``MailAccount``, ``MailPassword``,
160
+ ``MailFrom`` and ``MailUseTls`` — SSM first, then the environment,
161
+ following the standard ``/{Project}/{stage}/`` → ``/Global/{stage}/``
162
+ lookup order. Keyword arguments override any resolved value.
163
+ """
164
+ from velocity.aws import ssm_config
165
+
166
+ host = overrides.pop("host", None) or ssm_config.getenv("MailHost")
167
+ if not _clean(host):
168
+ raise MailConfigurationError(
169
+ "SmtpConfig.from_env: MailHost is not configured (checked SSM "
170
+ "and the environment)"
171
+ )
172
+ use_tls = overrides.pop("use_tls", None)
173
+ if use_tls is None:
174
+ use_tls = _clean(ssm_config.getenv("MailUseTls", "true")).lower() not in (
175
+ "false",
176
+ "0",
177
+ "no",
178
+ )
179
+ config = cls(
180
+ host=_clean(host),
181
+ port=int(overrides.pop("port", None) or ssm_config.getenv("MailPort", 587)),
182
+ username=overrides.pop("username", None)
183
+ or ssm_config.getenv("MailAccount"),
184
+ password=overrides.pop("password", None)
185
+ or ssm_config.getenv("MailPassword"),
186
+ sender=overrides.pop("sender", None) or ssm_config.getenv("MailFrom"),
187
+ use_tls=bool(use_tls),
188
+ )
189
+ return replace(config, **overrides) if overrides else config
190
+
191
+ @classmethod
192
+ def from_email_settings(
193
+ cls,
194
+ tx,
195
+ sys_id: int = DEFAULT_EMAIL_SETTINGS_ID,
196
+ obscured: bool = True,
197
+ **overrides,
198
+ ) -> "SmtpConfig":
199
+ """Build from a row of the ``email_settings`` table.
200
+
201
+ The legacy storage format rot13-obscures ``host_name``, ``user_name``
202
+ and ``password`` — that is obfuscation, not encryption, and is decoded
203
+ here for compatibility with existing rows. Pass ``obscured=False`` for
204
+ rows stored in the clear.
205
+ """
206
+ row = tx.table("email_settings").find(sys_id)
207
+ if not row:
208
+ raise MailConfigurationError(
209
+ f"SmtpConfig.from_email_settings: no email_settings row {sys_id}"
210
+ )
211
+ decode = _rot13 if obscured else _clean
212
+ config = cls(
213
+ host=decode(row.get("host_name")),
214
+ port=int(row.get("port") or 587),
215
+ username=decode(row.get("user_name")) or None,
216
+ password=decode(row.get("password")) or None,
217
+ sender=_clean(row.get("sender")) or None,
218
+ use_tls=bool(row.get("ttls")),
219
+ )
220
+ return replace(config, **overrides) if overrides else config
221
+
222
+
223
+ def build_message(
224
+ subject: str,
225
+ sender: str,
226
+ recipient: Any,
227
+ body: Optional[str] = None,
228
+ html: Optional[str] = None,
229
+ cc: Any = None,
230
+ bcc: Any = None,
231
+ reply_to: Any = None,
232
+ attachments: Optional[Dict[str, Any]] = None,
233
+ headers: Optional[Dict[str, str]] = None,
234
+ ) -> EmailMessage:
235
+ """Assemble an ``EmailMessage`` from plain text and/or HTML.
236
+
237
+ Pure: builds and returns, never connects. ``attachments`` maps a filename
238
+ to bytes, a readable object, or a path to read.
239
+ """
240
+ if not _clean(body) and not _clean(html):
241
+ raise ValueError("build_message: one of body or html is required")
242
+ if not _clean(sender):
243
+ raise ValueError("build_message: sender is required")
244
+
245
+ to = _as_list(recipient)
246
+ if not to:
247
+ raise ValueError("build_message: at least one recipient is required")
248
+
249
+ message = EmailMessage()
250
+ message["Subject"] = _clean(subject)
251
+ message["From"] = _clean(sender)
252
+ message["To"] = ", ".join(to)
253
+
254
+ cc_list = _as_list(cc)
255
+ if cc_list:
256
+ message["Cc"] = ", ".join(cc_list)
257
+ bcc_list = _as_list(bcc)
258
+ if bcc_list:
259
+ message["Bcc"] = ", ".join(bcc_list)
260
+ reply_to_list = _as_list(reply_to)
261
+ if reply_to_list:
262
+ message["Reply-To"] = ", ".join(reply_to_list)
263
+ for key, value in (headers or {}).items():
264
+ message[key] = value
265
+
266
+ # set_content then add_alternative yields multipart/alternative with the
267
+ # plain part first, which is the order mail clients expect.
268
+ if _clean(body):
269
+ message.set_content(body)
270
+ if _clean(html):
271
+ message.add_alternative(html, subtype="html")
272
+ else:
273
+ message.set_content(html, subtype="html")
274
+
275
+ for name, value in (attachments or {}).items():
276
+ if isinstance(value, bytes):
277
+ data = value
278
+ elif hasattr(value, "read"):
279
+ data = value.read()
280
+ elif isinstance(value, str):
281
+ with open(value, "rb") as handle:
282
+ data = handle.read()
283
+ else:
284
+ raise TypeError(
285
+ f"build_message: cannot attach {name!r} of type {type(value)}"
286
+ )
287
+ maintype, _, subtype = (
288
+ mimetypes.guess_type(name)[0] or "application/octet-stream"
289
+ ).partition("/")
290
+ message.add_attachment(
291
+ data, maintype=maintype, subtype=subtype or "octet-stream", filename=name
292
+ )
293
+
294
+ return message
295
+
296
+
297
+ class Mailer:
298
+ """Sends ``EmailMessage``s over SMTP.
299
+
300
+ ``connection_factory`` exists so tests (and alternate transports) can
301
+ supply their own connection; by default a ``smtplib.SMTP`` is opened per
302
+ send and closed again.
303
+ """
304
+
305
+ def __init__(self, config: SmtpConfig, connection_factory=None):
306
+ self.config = config
307
+ self._connection_factory = connection_factory
308
+
309
+ def _connect(self):
310
+ if self._connection_factory is not None:
311
+ return self._connection_factory(self.config)
312
+ return smtplib.SMTP(
313
+ self.config.host, self.config.port, timeout=self.config.timeout
314
+ )
315
+
316
+ def send_message(self, message: EmailMessage) -> EmailMessage:
317
+ """Deliver an already-built message; returns it for logging."""
318
+ recipients = []
319
+ for header in ("To", "Cc", "Bcc"):
320
+ value = message.get(header)
321
+ if value:
322
+ recipients.extend(
323
+ part.strip() for part in str(value).split(",") if part.strip()
324
+ )
325
+ if not recipients:
326
+ raise ValueError("Mailer.send_message: message has no recipients")
327
+
328
+ sender = _clean(message.get("From")) or _clean(self.config.sender)
329
+ connection = self._connect()
330
+ try:
331
+ connection.ehlo()
332
+ if self.config.use_tls:
333
+ connection.starttls()
334
+ connection.ehlo()
335
+ if _clean(self.config.username):
336
+ connection.login(self.config.username, self.config.password or "")
337
+ connection.sendmail(sender, recipients, message.as_string())
338
+ finally:
339
+ try:
340
+ connection.quit()
341
+ except Exception: # pragma: no cover - best-effort close
342
+ pass
343
+ return message
344
+
345
+ def send(
346
+ self,
347
+ subject: str,
348
+ recipient: Any,
349
+ body: Optional[str] = None,
350
+ html: Optional[str] = None,
351
+ sender: Optional[str] = None,
352
+ cc: Any = None,
353
+ bcc: Any = None,
354
+ reply_to: Any = None,
355
+ attachments: Optional[Dict[str, Any]] = None,
356
+ headers: Optional[Dict[str, str]] = None,
357
+ ) -> EmailMessage:
358
+ """Build and deliver one message."""
359
+ from_address = _clean(sender) or _clean(self.config.sender)
360
+ if not from_address:
361
+ raise MailConfigurationError(
362
+ "Mailer.send: no sender given and the config has no default (MailFrom)"
363
+ )
364
+ message = build_message(
365
+ subject=subject,
366
+ sender=from_address,
367
+ recipient=recipient,
368
+ body=body,
369
+ html=html,
370
+ cc=cc,
371
+ bcc=bcc,
372
+ reply_to=reply_to,
373
+ attachments=attachments,
374
+ headers=headers,
375
+ )
376
+ return self.send_message(message)
377
+
378
+
379
+ def _resolve_config(tx, email_settings_id: Optional[int]) -> SmtpConfig:
380
+ """email_settings row when one is named and reachable, otherwise Mail* keys.
381
+
382
+ A project can have the framework's default settings id without having the
383
+ table (velocity's own error notifier passes 1001 unconditionally), so a
384
+ missing row falls through to environment config rather than failing.
385
+ """
386
+ if email_settings_id is not None and tx is not None:
387
+ try:
388
+ return SmtpConfig.from_email_settings(tx, email_settings_id)
389
+ except MailConfigurationError:
390
+ pass
391
+ except Exception:
392
+ pass
393
+ return SmtpConfig.from_env()
394
+
395
+
396
+ def sendmail(
397
+ tx=None,
398
+ subject: str = "",
399
+ body: Optional[str] = None,
400
+ html: Optional[str] = None,
401
+ sender: Optional[str] = None,
402
+ recipient: Any = None,
403
+ cc: Any = None,
404
+ bcc: Any = None,
405
+ attachments: Optional[Dict[str, Any]] = None,
406
+ email_settings_id: Optional[int] = None,
407
+ reply_to: Any = None,
408
+ config: Optional[SmtpConfig] = None,
409
+ connection_factory=None,
410
+ log_table: Optional[str] = None,
411
+ ) -> EmailMessage:
412
+ """Send one message, resolving configuration the way each caller expects.
413
+
414
+ Signature-compatible with the application-supplied ``sendmail`` that
415
+ ``WebHandler.send_error_notification`` looks for, so it can serve as the
416
+ framework's own fallback.
417
+
418
+ Configuration precedence: an explicit ``config``; else the ``email_settings``
419
+ row named by ``email_settings_id`` (when both it and ``tx`` are given);
420
+ else the ``Mail*`` config keys.
421
+
422
+ When ``log_table`` is given and ``tx`` is available, one row per send is
423
+ written there — the table must already exist; this never creates schema.
424
+ """
425
+ if config is None:
426
+ config = _resolve_config(tx, email_settings_id)
427
+
428
+ message = Mailer(config, connection_factory=connection_factory).send(
429
+ subject=subject,
430
+ recipient=recipient,
431
+ body=body,
432
+ html=html,
433
+ sender=sender,
434
+ cc=cc,
435
+ bcc=bcc,
436
+ reply_to=reply_to,
437
+ attachments=attachments,
438
+ )
439
+
440
+ if log_table and tx is not None:
441
+ tx.table(log_table).insert(
442
+ {
443
+ "subject": _clean(subject),
444
+ "body": body,
445
+ "html": html,
446
+ "sender": _clean(message.get("From")),
447
+ "recipient": ", ".join(_as_list(recipient)),
448
+ "cc": ", ".join(_as_list(cc)) or None,
449
+ "bcc": ", ".join(_as_list(bcc)) or None,
450
+ }
451
+ )
452
+
453
+ return message