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.
- {velocity_python-0.1.95 → velocity_python-0.1.98}/PKG-INFO +2 -2
- {velocity_python-0.1.95 → velocity_python-0.1.98}/README.md +1 -1
- {velocity_python-0.1.95 → velocity_python-0.1.98}/pyproject.toml +1 -1
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/__init__.py +1 -1
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/mixins/web_handler.py +17 -3
- velocity_python-0.1.98/src/velocity/misc/mail.py +453 -0
- velocity_python-0.1.98/src/velocity/misc/tests/test_mail_send.py +365 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/PKG-INFO +2 -2
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/SOURCES.txt +1 -0
- velocity_python-0.1.95/src/velocity/misc/mail.py +0 -86
- {velocity_python-0.1.95 → velocity_python-0.1.98}/LICENSE +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/setup.cfg +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/amplify.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/amplify_build.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/backfill.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/indexing.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/references.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/service.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/assets/usage_index.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/config.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/operations.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/policies.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/reconcile.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/deploy/targets.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/dirty_pipeline.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/base_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/context.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/context_factory.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/exceptions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/lambda_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/masquerade.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/mixins/job_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/perf.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/response.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/handlers/sqs_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/job_results.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/s3.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/sms.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/ssm_config.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_amplify_build_policy_size.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_amplify_build_shim.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_deploy_policies.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_deploy_reconcile.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_response.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/aws/tests/test_sms.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/async_support.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/column.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/database.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/decorators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/engine.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/jsonproxy.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/result.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/row.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/sequence.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/table.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/transaction.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/core/view.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/exceptions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/maintenance.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/migrations.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/initializer.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/base/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/reserved.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/mysql/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/reserved.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/postgres/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/reserved.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlite/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/operators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/sql.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/sqlserver/types.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/servers/tablehelper.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/common_db_test.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/common.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/conftest.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_column.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_connections.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_database.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_engine.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_imports.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_result.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_row.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_table.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/sql/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/sql/common.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_db_utils.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_postgres.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_result_caching.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_sql_builder.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_tablehelper.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/tests/test_view_helper.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/db/utils.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/logging.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/conv/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/conv/iconv.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/conv/oconv.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/db.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/export.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/format.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/merge.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/pdf.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_db.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_fix.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_format.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_iconv.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_merge.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_oconv.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_original_error.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tests/test_timer.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/timer.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/misc/tools.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/core/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/core/capabilities.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/core/store.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/core/where.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/exceptions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/base/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/cassandra/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/cassandra/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/couchbase/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/couchbase/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/dynamodb/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/dynamodb/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/elasticsearch/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/elasticsearch/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/memory/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/mongodb/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/mongodb/translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/nosql/servers/redis/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/__init__.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/authorizenet_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/authorizenet_mirror.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/base_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/braintree_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/braintree_mirror.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/charge_rules.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/stripe_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity/payment/stripe_mirror.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/dependency_links.txt +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/entry_points.txt +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/requires.txt +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/src/velocity_python.egg-info/top_level.txt +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_activity_tracking_aborted_tx.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_amplify_build.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_asset_indexing.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_asset_references.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_assets_service.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_async_support.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_batch_operations.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_column_tx_arg.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_concurrency_safety.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_connection_pool.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_connection_resilience.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_context_job_descriptions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_db_credentials_ssm_cascade.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_db_maintenance.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_decorators.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_delete_hook_fk_blocked.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_dirty_pipeline_fast_path.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_email_processing.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_enqueue_send_failures.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_get_cognito_user_provider.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_http_handler_rollback.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_iconv_money_to_cents.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_identifier_injection_guard.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_job_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_job_results.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_json_columns.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_jsonb_dict_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_lambda_handler.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_lambda_handler_auth.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_lambda_handler_masquerade.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_masquerade_grant.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_mixins_import.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_n_plus_one.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_dynamodb.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_memory.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_mongodb.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_redis.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_translation.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_nosql_where.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_observability.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_on_commit.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_authorizenet_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_braintree_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_braintree_mirror.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_mirror_empty_table.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_profile_sorting.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_payment_stripe_adapter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_pdf.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_prepared_statements.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_psycopg3_upgrade.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_query_cache.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_restricted_direct_tables.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_retry_side_effect_guard.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_return_default_safety.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_row_batch_update.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_row_cache_staleness.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_schema_migrations.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_security_hardening.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_server_cursor.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_single_autocommit_safety.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_spreadsheet_functions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_sqlite_backend.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_sqs_per_record_transactions.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_ssm_config.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_store_user_data.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_sys_modified_count_postgres_demo.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_table_alter.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_transaction_class_wrapping.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_transaction_commit_and_ownership.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_transaction_edge_cases.py +0 -0
- {velocity_python-0.1.95 → velocity_python-0.1.98}/tests/test_where_clause_validation.py +0 -0
- {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.
|
|
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
|
|
{velocity_python-0.1.95 → velocity_python-0.1.98}/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,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
|