velocity-python 0.1.95__tar.gz → 0.1.99__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.
- {velocity_python-0.1.95 → velocity_python-0.1.99}/PKG-INFO +2 -2
- {velocity_python-0.1.95 → velocity_python-0.1.99}/README.md +1 -1
- {velocity_python-0.1.95 → velocity_python-0.1.99}/pyproject.toml +1 -1
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/__init__.py +1 -1
- velocity_python-0.1.99/src/velocity/aws/email.py +173 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/mixins/web_handler.py +17 -3
- velocity_python-0.1.99/src/velocity/aws/tests/test_email.py +344 -0
- velocity_python-0.1.99/src/velocity/misc/mail.py +571 -0
- velocity_python-0.1.99/src/velocity/misc/tests/test_mail_send.py +382 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity_python.egg-info/PKG-INFO +2 -2
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity_python.egg-info/SOURCES.txt +3 -0
- velocity_python-0.1.95/src/velocity/misc/mail.py +0 -86
- {velocity_python-0.1.95 → velocity_python-0.1.99}/LICENSE +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/setup.cfg +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/amplify.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/amplify_build.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/assets/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/assets/backfill.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/assets/indexing.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/assets/references.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/assets/service.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/assets/usage_index.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/deploy/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/deploy/config.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/deploy/operations.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/deploy/policies.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/deploy/reconcile.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/deploy/targets.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/dirty_pipeline.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/base_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/context.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/context_factory.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/exceptions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/lambda_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/masquerade.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/mixins/job_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/perf.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/response.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/sqs_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/job_results.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/s3.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/sms.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/ssm_config.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/tests/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/tests/test_amplify_build_policy_size.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/tests/test_amplify_build_shim.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/tests/test_deploy_policies.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/tests/test_deploy_reconcile.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/tests/test_response.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/tests/test_sms.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/async_support.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/column.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/database.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/decorators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/engine.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/jsonproxy.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/result.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/row.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/sequence.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/table.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/transaction.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/core/view.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/exceptions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/maintenance.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/migrations.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/base/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/base/initializer.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/base/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/base/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/base/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/reserved.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/mysql/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/reserved.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/postgres/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/reserved.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlite/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/sqlserver/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/servers/tablehelper.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/common_db_test.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/common.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/conftest.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_column.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_connections.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_database.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_engine.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_imports.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_result.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_row.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_table.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/sql/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/sql/common.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_db_utils.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_postgres.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_result_caching.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_sql_builder.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_tablehelper.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/tests/test_view_helper.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/db/utils.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/logging.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/conv/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/conv/iconv.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/conv/oconv.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/db.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/export.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/format.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/merge.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/pdf.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tests/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tests/test_db.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tests/test_fix.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tests/test_format.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tests/test_iconv.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tests/test_merge.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tests/test_oconv.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tests/test_original_error.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tests/test_timer.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/timer.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/misc/tools.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/core/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/core/capabilities.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/core/store.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/core/where.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/exceptions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/base/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/cassandra/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/cassandra/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/couchbase/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/couchbase/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/dynamodb/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/dynamodb/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/elasticsearch/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/elasticsearch/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/memory/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/mongodb/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/mongodb/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/nosql/servers/redis/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/payment/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/payment/authorizenet_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/payment/authorizenet_mirror.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/payment/base_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/payment/braintree_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/payment/braintree_mirror.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/payment/charge_rules.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/payment/stripe_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/payment/stripe_mirror.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity_python.egg-info/dependency_links.txt +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity_python.egg-info/entry_points.txt +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity_python.egg-info/requires.txt +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity_python.egg-info/top_level.txt +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_activity_tracking_aborted_tx.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_amplify_build.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_asset_indexing.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_asset_references.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_assets_service.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_async_support.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_batch_operations.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_column_tx_arg.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_concurrency_safety.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_connection_pool.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_connection_resilience.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_context_job_descriptions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_db_credentials_ssm_cascade.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_db_maintenance.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_decorators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_delete_hook_fk_blocked.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_dirty_pipeline_fast_path.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_email_processing.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_enqueue_send_failures.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_get_cognito_user_provider.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_http_handler_rollback.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_iconv_money_to_cents.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_identifier_injection_guard.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_job_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_job_results.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_json_columns.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_jsonb_dict_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_lambda_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_lambda_handler_auth.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_lambda_handler_masquerade.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_masquerade_grant.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_mixins_import.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_n_plus_one.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_nosql_dynamodb.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_nosql_memory.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_nosql_mongodb.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_nosql_redis.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_nosql_translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_nosql_where.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_observability.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_on_commit.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_payment_authorizenet_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_payment_braintree_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_payment_braintree_mirror.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_payment_mirror_empty_table.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_payment_profile_sorting.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_payment_stripe_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_pdf.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_prepared_statements.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_psycopg3_upgrade.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_query_cache.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_restricted_direct_tables.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_retry_side_effect_guard.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_return_default_safety.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_row_batch_update.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_row_cache_staleness.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_schema_migrations.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_security_hardening.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_server_cursor.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_single_autocommit_safety.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_spreadsheet_functions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_sqlite_backend.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_sqs_per_record_transactions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_ssm_config.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_store_user_data.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_sys_modified_count_postgres_demo.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_table_alter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_transaction_class_wrapping.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_transaction_commit_and_ownership.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_transaction_edge_cases.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/tests/test_where_clause_validation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.99}/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.
|
|
3
|
+
Version: 0.1.99
|
|
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
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Outbound email over Amazon SES.
|
|
2
|
+
|
|
3
|
+
The counterpart to ``velocity.aws.sms``: same shape, same injectable-client
|
|
4
|
+
pattern, same "lazily construct boto3 on first use" behaviour.
|
|
5
|
+
|
|
6
|
+
Why this exists alongside the SMTP sender in ``velocity.misc.mail``: SES
|
|
7
|
+
authenticates with the caller's **IAM role**, so there is no password to store,
|
|
8
|
+
rotate, obfuscate, or leak. An SMTP relay needs a credential kept somewhere,
|
|
9
|
+
and "somewhere" is where credentials rot — in a config key, in a database
|
|
10
|
+
column, in two places that drift apart.
|
|
11
|
+
|
|
12
|
+
Messages are handed to SES as raw MIME (``Content.Raw``) rather than as the
|
|
13
|
+
simple subject/body structure, because ``velocity.misc.mail.build_message``
|
|
14
|
+
already produces a correct ``EmailMessage`` — multipart/alternative, Reply-To,
|
|
15
|
+
attachments and all — and re-expressing that in SES's simple format would only
|
|
16
|
+
lose fidelity.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from email.message import EmailMessage
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
__all__ = ["EmailClient", "SesError"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SesError(Exception):
|
|
31
|
+
"""Raised when SES rejects a message."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _header_addresses(message: EmailMessage, header: str) -> List[str]:
|
|
35
|
+
value = message.get(header)
|
|
36
|
+
if not value:
|
|
37
|
+
return []
|
|
38
|
+
return [part.strip() for part in str(value).split(",") if part.strip()]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EmailClient:
|
|
42
|
+
"""Outbound email via Amazon SES (v2).
|
|
43
|
+
|
|
44
|
+
``sender`` is the default From address and must be a verified SES identity
|
|
45
|
+
(or belong to a verified domain). A per-call ``sender`` overrides it.
|
|
46
|
+
|
|
47
|
+
``configuration_set`` is how bounce and complaint events get published —
|
|
48
|
+
set one up pointing at an SNS topic and SES will report what happened to
|
|
49
|
+
each message, which is the part SMTP never told you.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
sender: Optional[str] = None,
|
|
55
|
+
configuration_set: Optional[str] = None,
|
|
56
|
+
client=None,
|
|
57
|
+
region: Optional[str] = None,
|
|
58
|
+
):
|
|
59
|
+
self.sender = sender
|
|
60
|
+
self.configuration_set = configuration_set
|
|
61
|
+
self.region = region
|
|
62
|
+
self._client = client
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def client(self):
|
|
66
|
+
if self._client is None:
|
|
67
|
+
import boto3
|
|
68
|
+
|
|
69
|
+
kwargs = {"region_name": self.region} if self.region else {}
|
|
70
|
+
self._client = boto3.client("sesv2", **kwargs)
|
|
71
|
+
return self._client
|
|
72
|
+
|
|
73
|
+
def send_message(
|
|
74
|
+
self,
|
|
75
|
+
message: EmailMessage,
|
|
76
|
+
sender: Optional[str] = None,
|
|
77
|
+
configuration_set: Optional[str] = None,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Deliver an already-built ``EmailMessage``; returns the SES message id.
|
|
80
|
+
|
|
81
|
+
Bcc recipients are passed to SES as destinations and the Bcc header is
|
|
82
|
+
removed from the transmitted MIME — a Bcc header left in place is
|
|
83
|
+
visible to every recipient, which defeats the point of blind copying.
|
|
84
|
+
"""
|
|
85
|
+
to = _header_addresses(message, "To")
|
|
86
|
+
cc = _header_addresses(message, "Cc")
|
|
87
|
+
bcc = _header_addresses(message, "Bcc")
|
|
88
|
+
if not (to or cc or bcc):
|
|
89
|
+
raise ValueError("EmailClient.send_message: message has no recipients")
|
|
90
|
+
|
|
91
|
+
if bcc:
|
|
92
|
+
del message["Bcc"]
|
|
93
|
+
|
|
94
|
+
from_address = str(sender or message.get("From") or self.sender or "").strip()
|
|
95
|
+
if not from_address:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"EmailClient.send_message: no sender given and the client has no default"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
params: Dict[str, Any] = {
|
|
101
|
+
"FromEmailAddress": from_address,
|
|
102
|
+
"Destination": {"ToAddresses": to},
|
|
103
|
+
"Content": {"Raw": {"Data": message.as_bytes()}},
|
|
104
|
+
}
|
|
105
|
+
if cc:
|
|
106
|
+
params["Destination"]["CcAddresses"] = cc
|
|
107
|
+
if bcc:
|
|
108
|
+
params["Destination"]["BccAddresses"] = bcc
|
|
109
|
+
configuration_set = configuration_set or self.configuration_set
|
|
110
|
+
if configuration_set:
|
|
111
|
+
params["ConfigurationSetName"] = configuration_set
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
response = self.client.send_email(**params)
|
|
115
|
+
except Exception as exc: # surface SES's own message, typed
|
|
116
|
+
raise SesError(f"SES rejected the message: {exc}") from exc
|
|
117
|
+
return response.get("MessageId", "")
|
|
118
|
+
|
|
119
|
+
def send(
|
|
120
|
+
self,
|
|
121
|
+
subject: str,
|
|
122
|
+
recipient: Any,
|
|
123
|
+
body: Optional[str] = None,
|
|
124
|
+
html: Optional[str] = None,
|
|
125
|
+
sender: Optional[str] = None,
|
|
126
|
+
cc: Any = None,
|
|
127
|
+
bcc: Any = None,
|
|
128
|
+
reply_to: Any = None,
|
|
129
|
+
attachments: Optional[Dict[str, Any]] = None,
|
|
130
|
+
headers: Optional[Dict[str, str]] = None,
|
|
131
|
+
configuration_set: Optional[str] = None,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""Build and deliver one message; returns the SES message id."""
|
|
134
|
+
# Imported here rather than at module scope: misc.mail imports nothing
|
|
135
|
+
# from aws, and keeping the dependency one-directional avoids a cycle.
|
|
136
|
+
from velocity.misc.mail import build_message
|
|
137
|
+
|
|
138
|
+
from_address = str(sender or self.sender or "").strip()
|
|
139
|
+
if not from_address:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
"EmailClient.send: no sender given and the client has no default "
|
|
142
|
+
"(set MailFrom, or pass sender=)"
|
|
143
|
+
)
|
|
144
|
+
message = build_message(
|
|
145
|
+
subject=subject,
|
|
146
|
+
sender=from_address,
|
|
147
|
+
recipient=recipient,
|
|
148
|
+
body=body,
|
|
149
|
+
html=html,
|
|
150
|
+
cc=cc,
|
|
151
|
+
bcc=bcc,
|
|
152
|
+
reply_to=reply_to,
|
|
153
|
+
attachments=attachments,
|
|
154
|
+
headers=headers,
|
|
155
|
+
)
|
|
156
|
+
return self.send_message(message, configuration_set=configuration_set)
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_env(cls, **overrides) -> "EmailClient":
|
|
160
|
+
"""Build from config: ``MailFrom``, ``MailConfigurationSet``, ``AWS_REGION``."""
|
|
161
|
+
from velocity.aws import ssm_config
|
|
162
|
+
|
|
163
|
+
sender = overrides.pop("sender", None) or ssm_config.getenv("MailFrom")
|
|
164
|
+
configuration_set = overrides.pop(
|
|
165
|
+
"configuration_set", None
|
|
166
|
+
) or ssm_config.getenv("MailConfigurationSet")
|
|
167
|
+
region = overrides.pop("region", None) or ssm_config.get_region(None)
|
|
168
|
+
return cls(
|
|
169
|
+
sender=sender,
|
|
170
|
+
configuration_set=configuration_set,
|
|
171
|
+
region=region,
|
|
172
|
+
**overrides,
|
|
173
|
+
)
|
{velocity_python-0.1.95 → velocity_python-0.1.99}/src/velocity/aws/handlers/mixins/web_handler.py
RENAMED
|
@@ -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
|
-
|
|
410
|
+
app_sendmail = getattr(helpers, "sendmail", None)
|
|
411
|
+
if app_sendmail is not None:
|
|
412
|
+
return app_sendmail
|
|
403
413
|
except ImportError:
|
|
404
|
-
|
|
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,344 @@
|
|
|
1
|
+
"""Unit tests for velocity.aws.email (SES) and the transport selection in
|
|
2
|
+
velocity.misc.mail.sendmail.
|
|
3
|
+
|
|
4
|
+
Nothing here talks to AWS: EmailClient takes an injectable client, the same way
|
|
5
|
+
SmsClient does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import unittest
|
|
9
|
+
from email import message_from_bytes
|
|
10
|
+
from email import policy
|
|
11
|
+
|
|
12
|
+
from velocity.aws.email import EmailClient, SesError
|
|
13
|
+
from velocity.misc.mail import (
|
|
14
|
+
TRANSPORT_SES,
|
|
15
|
+
TRANSPORT_SMTP,
|
|
16
|
+
build_message,
|
|
17
|
+
resolve_transport,
|
|
18
|
+
sendmail,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeSes:
|
|
23
|
+
"""Records what would have been handed to SES."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, error=None):
|
|
26
|
+
self.calls = []
|
|
27
|
+
self.error = error
|
|
28
|
+
|
|
29
|
+
def send_email(self, **params):
|
|
30
|
+
self.calls.append(params)
|
|
31
|
+
if self.error:
|
|
32
|
+
raise self.error
|
|
33
|
+
return {"MessageId": "ses-message-id-1"}
|
|
34
|
+
|
|
35
|
+
# Convenience: the raw MIME of the most recent call, parsed back.
|
|
36
|
+
def last_message(self):
|
|
37
|
+
# policy=default so the round trip yields an EmailMessage (with
|
|
38
|
+
# iter_attachments), not a legacy compat32 Message.
|
|
39
|
+
return message_from_bytes(
|
|
40
|
+
self.calls[-1]["Content"]["Raw"]["Data"], policy=policy.default
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FakeTable:
|
|
45
|
+
def __init__(self, inserts):
|
|
46
|
+
self.inserts = inserts
|
|
47
|
+
|
|
48
|
+
def insert(self, data):
|
|
49
|
+
self.inserts.append(dict(data))
|
|
50
|
+
return dict(data)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FakeTx:
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.inserts = []
|
|
56
|
+
|
|
57
|
+
def table(self, name):
|
|
58
|
+
return FakeTable(self.inserts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestEmailClient(unittest.TestCase):
|
|
62
|
+
def client(self, **kwargs):
|
|
63
|
+
ses = FakeSes(error=kwargs.pop("error", None))
|
|
64
|
+
return EmailClient(client=ses, **kwargs), ses
|
|
65
|
+
|
|
66
|
+
def test_send_delivers_raw_mime_with_destinations(self):
|
|
67
|
+
client, ses = self.client(sender="Tenspace <info@example.com>")
|
|
68
|
+
message_id = client.send(
|
|
69
|
+
subject="Hello",
|
|
70
|
+
recipient="visitor@example.com",
|
|
71
|
+
body="Plain",
|
|
72
|
+
html="<p>Rich</p>",
|
|
73
|
+
)
|
|
74
|
+
self.assertEqual(message_id, "ses-message-id-1")
|
|
75
|
+
params = ses.calls[0]
|
|
76
|
+
self.assertEqual(params["FromEmailAddress"], "Tenspace <info@example.com>")
|
|
77
|
+
self.assertEqual(params["Destination"]["ToAddresses"], ["visitor@example.com"])
|
|
78
|
+
parsed = ses.last_message()
|
|
79
|
+
self.assertEqual(parsed["Subject"], "Hello")
|
|
80
|
+
self.assertTrue(parsed.is_multipart())
|
|
81
|
+
|
|
82
|
+
def test_reply_to_and_attachments_survive_the_raw_round_trip(self):
|
|
83
|
+
client, ses = self.client(sender="info@example.com")
|
|
84
|
+
client.send(
|
|
85
|
+
subject="Hello",
|
|
86
|
+
recipient="visitor@example.com",
|
|
87
|
+
body="Plain",
|
|
88
|
+
reply_to="sales@example.com",
|
|
89
|
+
attachments={"note.txt": b"file body"},
|
|
90
|
+
)
|
|
91
|
+
parsed = ses.last_message()
|
|
92
|
+
self.assertEqual(parsed["Reply-To"], "sales@example.com")
|
|
93
|
+
self.assertEqual(
|
|
94
|
+
[p.get_filename() for p in parsed.iter_attachments()], ["note.txt"]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def test_bcc_is_a_destination_but_never_a_transmitted_header(self):
|
|
98
|
+
# A Bcc header left in the MIME is shown to every recipient, which is
|
|
99
|
+
# precisely what a blind copy must not do.
|
|
100
|
+
client, ses = self.client(sender="info@example.com")
|
|
101
|
+
client.send(
|
|
102
|
+
subject="Hello",
|
|
103
|
+
recipient="visitor@example.com",
|
|
104
|
+
body="Plain",
|
|
105
|
+
cc="cc@example.com",
|
|
106
|
+
bcc="secret@example.com",
|
|
107
|
+
)
|
|
108
|
+
params = ses.calls[0]
|
|
109
|
+
self.assertEqual(params["Destination"]["CcAddresses"], ["cc@example.com"])
|
|
110
|
+
self.assertEqual(params["Destination"]["BccAddresses"], ["secret@example.com"])
|
|
111
|
+
parsed = ses.last_message()
|
|
112
|
+
self.assertIsNone(parsed["Bcc"])
|
|
113
|
+
self.assertEqual(parsed["Cc"], "cc@example.com")
|
|
114
|
+
|
|
115
|
+
def test_configuration_set_is_passed_when_set(self):
|
|
116
|
+
client, ses = self.client(
|
|
117
|
+
sender="info@example.com", configuration_set="tracking"
|
|
118
|
+
)
|
|
119
|
+
client.send(subject="Hello", recipient="a@example.com", body="x")
|
|
120
|
+
self.assertEqual(ses.calls[0]["ConfigurationSetName"], "tracking")
|
|
121
|
+
|
|
122
|
+
def test_no_configuration_set_key_when_unset(self):
|
|
123
|
+
client, ses = self.client(sender="info@example.com")
|
|
124
|
+
client.send(subject="Hello", recipient="a@example.com", body="x")
|
|
125
|
+
self.assertNotIn("ConfigurationSetName", ses.calls[0])
|
|
126
|
+
|
|
127
|
+
def test_sender_is_required(self):
|
|
128
|
+
client, _ = self.client()
|
|
129
|
+
with self.assertRaises(ValueError):
|
|
130
|
+
client.send(subject="Hello", recipient="a@example.com", body="x")
|
|
131
|
+
|
|
132
|
+
def test_message_without_recipients_is_rejected(self):
|
|
133
|
+
client, _ = self.client(sender="info@example.com")
|
|
134
|
+
message = build_message(
|
|
135
|
+
subject="s", sender="info@example.com", recipient="a@example.com", body="x"
|
|
136
|
+
)
|
|
137
|
+
del message["To"]
|
|
138
|
+
with self.assertRaises(ValueError):
|
|
139
|
+
client.send_message(message)
|
|
140
|
+
|
|
141
|
+
def test_ses_failures_are_typed(self):
|
|
142
|
+
client, _ = self.client(
|
|
143
|
+
sender="info@example.com", error=RuntimeError("Throttled")
|
|
144
|
+
)
|
|
145
|
+
with self.assertRaises(SesError) as caught:
|
|
146
|
+
client.send(subject="Hello", recipient="a@example.com", body="x")
|
|
147
|
+
self.assertIn("Throttled", str(caught.exception))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestTransportSelection(unittest.TestCase):
|
|
151
|
+
def test_explicit_argument_wins(self):
|
|
152
|
+
self.assertEqual(resolve_transport("ses"), TRANSPORT_SES)
|
|
153
|
+
self.assertEqual(resolve_transport("SMTP"), TRANSPORT_SMTP)
|
|
154
|
+
|
|
155
|
+
def test_sendmail_routes_to_ses_and_logs_the_send(self):
|
|
156
|
+
ses = FakeSes()
|
|
157
|
+
tx = FakeTx()
|
|
158
|
+
message = sendmail(
|
|
159
|
+
tx,
|
|
160
|
+
subject="Hello",
|
|
161
|
+
body="Plain",
|
|
162
|
+
html="<p>Rich</p>",
|
|
163
|
+
sender="Tenspace <info@example.com>",
|
|
164
|
+
recipient="visitor@example.com",
|
|
165
|
+
reply_to="visitor@example.com",
|
|
166
|
+
transport="ses",
|
|
167
|
+
client=ses,
|
|
168
|
+
log_table="sys_log_email",
|
|
169
|
+
)
|
|
170
|
+
self.assertEqual(len(ses.calls), 1)
|
|
171
|
+
self.assertEqual(message["Subject"], "Hello")
|
|
172
|
+
self.assertEqual(len(tx.inserts), 1)
|
|
173
|
+
self.assertEqual(tx.inserts[0]["recipient"], "visitor@example.com")
|
|
174
|
+
|
|
175
|
+
def test_ses_transport_needs_no_smtp_credential(self):
|
|
176
|
+
# The point of the migration: no email_settings row, no Mail* password,
|
|
177
|
+
# no tx required for configuration.
|
|
178
|
+
ses = FakeSes()
|
|
179
|
+
sendmail(
|
|
180
|
+
None,
|
|
181
|
+
subject="Hello",
|
|
182
|
+
body="Plain",
|
|
183
|
+
sender="info@example.com",
|
|
184
|
+
recipient="a@example.com",
|
|
185
|
+
transport="ses",
|
|
186
|
+
client=ses,
|
|
187
|
+
)
|
|
188
|
+
self.assertEqual(len(ses.calls), 1)
|
|
189
|
+
|
|
190
|
+
def test_ses_is_the_default_transport(self):
|
|
191
|
+
import os
|
|
192
|
+
|
|
193
|
+
saved = os.environ.get("MailTransport")
|
|
194
|
+
os.environ.pop("MailTransport", None)
|
|
195
|
+
os.environ["VELOCITY_SSM_ENABLED"] = "false"
|
|
196
|
+
try:
|
|
197
|
+
from velocity.aws import ssm_config
|
|
198
|
+
|
|
199
|
+
ssm_config.clear_cache()
|
|
200
|
+
self.assertEqual(resolve_transport(), TRANSPORT_SES)
|
|
201
|
+
finally:
|
|
202
|
+
if saved is not None:
|
|
203
|
+
os.environ["MailTransport"] = saved
|
|
204
|
+
|
|
205
|
+
def test_mail_transport_config_key_pins_smtp(self):
|
|
206
|
+
import os
|
|
207
|
+
|
|
208
|
+
saved = os.environ.get("MailTransport")
|
|
209
|
+
os.environ["MailTransport"] = "smtp"
|
|
210
|
+
os.environ["VELOCITY_SSM_ENABLED"] = "false"
|
|
211
|
+
try:
|
|
212
|
+
from velocity.aws import ssm_config
|
|
213
|
+
|
|
214
|
+
ssm_config.clear_cache()
|
|
215
|
+
self.assertEqual(resolve_transport(), TRANSPORT_SMTP)
|
|
216
|
+
finally:
|
|
217
|
+
if saved is None:
|
|
218
|
+
os.environ.pop("MailTransport", None)
|
|
219
|
+
else:
|
|
220
|
+
os.environ["MailTransport"] = saved
|
|
221
|
+
|
|
222
|
+
def test_mail_transport_config_key_selects_ses(self):
|
|
223
|
+
import os
|
|
224
|
+
|
|
225
|
+
saved = os.environ.get("MailTransport")
|
|
226
|
+
os.environ["MailTransport"] = "ses"
|
|
227
|
+
os.environ["VELOCITY_SSM_ENABLED"] = "false"
|
|
228
|
+
try:
|
|
229
|
+
from velocity.aws import ssm_config
|
|
230
|
+
|
|
231
|
+
ssm_config.clear_cache()
|
|
232
|
+
self.assertEqual(resolve_transport(), TRANSPORT_SES)
|
|
233
|
+
finally:
|
|
234
|
+
if saved is None:
|
|
235
|
+
os.environ.pop("MailTransport", None)
|
|
236
|
+
else:
|
|
237
|
+
os.environ["MailTransport"] = saved
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class TestSesFallsBackToSmtp(unittest.TestCase):
|
|
241
|
+
"""SES is the default; SMTP catches it when SES cannot deliver."""
|
|
242
|
+
|
|
243
|
+
def smtp_config(self):
|
|
244
|
+
from velocity.misc.mail import SmtpConfig
|
|
245
|
+
|
|
246
|
+
return SmtpConfig(
|
|
247
|
+
host="smtp.example.com",
|
|
248
|
+
username="test-user@example.com",
|
|
249
|
+
password="test-password",
|
|
250
|
+
sender="Tenspace <info@example.com>",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def test_ses_failure_delivers_over_smtp(self):
|
|
254
|
+
from velocity.misc.tests.test_mail_send import FakeSmtp
|
|
255
|
+
|
|
256
|
+
ses = FakeSes(error=RuntimeError("Throttled"))
|
|
257
|
+
smtp = FakeSmtp()
|
|
258
|
+
with self.assertLogs("velocity.misc.mail", level="WARNING"):
|
|
259
|
+
message = sendmail(
|
|
260
|
+
None,
|
|
261
|
+
subject="Hello",
|
|
262
|
+
body="Plain",
|
|
263
|
+
sender="info@example.com",
|
|
264
|
+
recipient="a@example.com",
|
|
265
|
+
transport="ses",
|
|
266
|
+
client=ses,
|
|
267
|
+
config=self.smtp_config(),
|
|
268
|
+
connection_factory=lambda _c: smtp,
|
|
269
|
+
)
|
|
270
|
+
self.assertEqual(len(ses.calls), 1)
|
|
271
|
+
self.assertEqual(len(smtp.sent), 1)
|
|
272
|
+
self.assertEqual(message["Subject"], "Hello")
|
|
273
|
+
|
|
274
|
+
def test_smtp_is_untouched_when_ses_succeeds(self):
|
|
275
|
+
from velocity.misc.tests.test_mail_send import FakeSmtp
|
|
276
|
+
|
|
277
|
+
ses = FakeSes()
|
|
278
|
+
smtp = FakeSmtp()
|
|
279
|
+
sendmail(
|
|
280
|
+
None,
|
|
281
|
+
subject="Hello",
|
|
282
|
+
body="Plain",
|
|
283
|
+
sender="info@example.com",
|
|
284
|
+
recipient="a@example.com",
|
|
285
|
+
transport="ses",
|
|
286
|
+
client=ses,
|
|
287
|
+
config=self.smtp_config(),
|
|
288
|
+
connection_factory=lambda _c: smtp,
|
|
289
|
+
)
|
|
290
|
+
self.assertEqual(len(ses.calls), 1)
|
|
291
|
+
self.assertEqual(smtp.sent, [])
|
|
292
|
+
|
|
293
|
+
def test_both_failing_reports_both_causes(self):
|
|
294
|
+
from velocity.misc.mail import MailDeliveryError
|
|
295
|
+
|
|
296
|
+
ses = FakeSes(error=RuntimeError("Throttled"))
|
|
297
|
+
|
|
298
|
+
def exploding_smtp(_config):
|
|
299
|
+
from velocity.misc.tests.test_mail_send import FakeSmtp
|
|
300
|
+
|
|
301
|
+
smtp = FakeSmtp()
|
|
302
|
+
|
|
303
|
+
def boom(*_a, **_k):
|
|
304
|
+
raise RuntimeError("relay refused")
|
|
305
|
+
|
|
306
|
+
smtp.sendmail = boom
|
|
307
|
+
return smtp
|
|
308
|
+
|
|
309
|
+
with self.assertLogs("velocity.misc.mail", level="WARNING"):
|
|
310
|
+
with self.assertRaises(MailDeliveryError) as caught:
|
|
311
|
+
sendmail(
|
|
312
|
+
None,
|
|
313
|
+
subject="Hello",
|
|
314
|
+
body="Plain",
|
|
315
|
+
sender="info@example.com",
|
|
316
|
+
recipient="a@example.com",
|
|
317
|
+
transport="ses",
|
|
318
|
+
client=ses,
|
|
319
|
+
config=self.smtp_config(),
|
|
320
|
+
connection_factory=exploding_smtp,
|
|
321
|
+
)
|
|
322
|
+
text = str(caught.exception)
|
|
323
|
+
self.assertIn("Throttled", text)
|
|
324
|
+
self.assertIn("relay refused", text)
|
|
325
|
+
|
|
326
|
+
def test_fallback_can_be_disabled(self):
|
|
327
|
+
ses = FakeSes(error=RuntimeError("Throttled"))
|
|
328
|
+
with self.assertRaises(Exception) as caught:
|
|
329
|
+
sendmail(
|
|
330
|
+
None,
|
|
331
|
+
subject="Hello",
|
|
332
|
+
body="Plain",
|
|
333
|
+
sender="info@example.com",
|
|
334
|
+
recipient="a@example.com",
|
|
335
|
+
transport="ses",
|
|
336
|
+
client=ses,
|
|
337
|
+
config=self.smtp_config(),
|
|
338
|
+
fallback=False,
|
|
339
|
+
)
|
|
340
|
+
self.assertIn("Throttled", str(caught.exception))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
unittest.main()
|