From 6f170410e8ccbd0f2faf3c7dbc95e5f057b79617 Mon Sep 17 00:00:00 2001 From: rafcio19 Date: Mon, 1 May 2023 19:15:29 +0100 Subject: [PATCH] SES v2 (#6259) * feat: add ses v2 * feat: move wrap v1 logic into v2 api * feat: v2 api on v2 url * chore: types * chore: linting * feat: raw emails * chore: linting * feat: add list_contacts * fix: urls need to be explicit for this to work with moto server * chore: linting * chore: remodel * chore: rework * chore: cleanup * chore: fix test * chore: sort out mypy * feat: add contact lists * fix: new url for server mode * feat: create and delete * chore: linting * chore: linting * chore: refactor * chore: match errors with real responses * chore: linting * chore: run implementation coverage script * refactor: easier, faster look ups with dicts * refactor: contact is now a child of contactlist * tests: contactlists return 200 if empty, contacts do not * chore: update botocore and run implementation coverage script * refactor: add matching *_contact methods to backend model so coverage script can detect them --- IMPLEMENTATION_COVERAGE.md | 145 ++++++++++-- docs/docs/services/athena.rst | 7 + docs/docs/services/datasync.rst | 13 ++ docs/docs/services/emr-containers.rst | 1 + docs/docs/services/glue.rst | 16 +- docs/docs/services/guardduty.rst | 1 + docs/docs/services/pinpoint.rst | 3 + docs/docs/services/sesv2.rst | 116 +++++++++ docs/docs/services/ssm.rst | 6 +- moto/__init__.py | 1 + moto/backend_index.py | 2 + moto/moto_server/werkzeug_app.py | 2 + moto/sesv2/__init__.py | 5 + moto/sesv2/exceptions.py | 8 + moto/sesv2/models.py | 166 +++++++++++++ moto/sesv2/responses.py | 102 ++++++++ moto/sesv2/urls.py | 19 ++ scripts/implementation_coverage.py | 1 + tests/test_sesv2/__init__.py | 0 tests/test_sesv2/test_server.py | 13 ++ tests/test_sesv2/test_sesv2.py | 323 ++++++++++++++++++++++++++ 21 files changed, 923 insertions(+), 27 deletions(-) create mode 100644 docs/docs/services/sesv2.rst create mode 100644 moto/sesv2/__init__.py create mode 100644 moto/sesv2/exceptions.py create mode 100644 moto/sesv2/models.py create mode 100644 moto/sesv2/responses.py create mode 100644 moto/sesv2/urls.py create mode 100644 tests/test_sesv2/__init__.py create mode 100644 tests/test_sesv2/test_server.py create mode 100644 tests/test_sesv2/test_sesv2.py diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 39449b890..483e84d0c 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -358,11 +358,13 @@ ## athena
-26% implemented +23% implemented - [ ] batch_get_named_query - [ ] batch_get_prepared_statement - [ ] batch_get_query_execution +- [ ] cancel_capacity_reservation +- [ ] create_capacity_reservation - [X] create_data_catalog - [X] create_named_query - [ ] create_notebook @@ -378,6 +380,8 @@ - [ ] get_calculation_execution - [ ] get_calculation_execution_code - [ ] get_calculation_execution_status +- [ ] get_capacity_assignment_configuration +- [ ] get_capacity_reservation - [X] get_data_catalog - [ ] get_database - [X] get_named_query @@ -393,6 +397,7 @@ - [ ] import_notebook - [ ] list_application_dpu_sizes - [ ] list_calculation_executions +- [ ] list_capacity_reservations - [X] list_data_catalogs - [ ] list_databases - [ ] list_engine_versions @@ -406,6 +411,7 @@ - [ ] list_table_metadata - [ ] list_tags_for_resource - [X] list_work_groups +- [ ] put_capacity_assignment_configuration - [ ] start_calculation_execution - [X] start_query_execution - [ ] start_session @@ -414,6 +420,7 @@ - [ ] tag_resource - [ ] terminate_session - [ ] untag_resource +- [ ] update_capacity_reservation - [ ] update_data_catalog - [ ] update_named_query - [ ] update_notebook @@ -1453,8 +1460,9 @@ ## datasync
-13% implemented +10% implemented +- [ ] add_storage_system - [X] cancel_task_execution - [ ] create_agent - [ ] create_location_efs @@ -1472,6 +1480,7 @@ - [X] delete_location - [X] delete_task - [ ] describe_agent +- [ ] describe_discovery_job - [ ] describe_location_efs - [ ] describe_location_fsx_lustre - [ ] describe_location_fsx_ontap @@ -1482,21 +1491,32 @@ - [ ] describe_location_object_storage - [ ] describe_location_s3 - [ ] describe_location_smb +- [ ] describe_storage_system +- [ ] describe_storage_system_resource_metrics +- [ ] describe_storage_system_resources - [ ] describe_task - [ ] describe_task_execution +- [ ] generate_recommendations - [ ] list_agents +- [ ] list_discovery_jobs - [ ] list_locations +- [ ] list_storage_systems - [ ] list_tags_for_resource - [ ] list_task_executions - [ ] list_tasks +- [ ] remove_storage_system +- [ ] start_discovery_job - [X] start_task_execution +- [ ] stop_discovery_job - [ ] tag_resource - [ ] untag_resource - [ ] update_agent +- [ ] update_discovery_job - [ ] update_location_hdfs - [ ] update_location_nfs - [ ] update_location_object_storage - [ ] update_location_smb +- [ ] update_storage_system - [X] update_task - [ ] update_task_execution
@@ -2831,7 +2851,7 @@ ## emr-containers
-42% implemented +40% implemented - [X] cancel_job_run - [ ] create_job_template @@ -2844,6 +2864,7 @@ - [ ] describe_job_template - [ ] describe_managed_endpoint - [X] describe_virtual_cluster +- [ ] get_managed_endpoint_session_credentials - [X] list_job_runs - [ ] list_job_templates - [ ] list_managed_endpoints @@ -3121,7 +3142,7 @@ ## glue
-26% implemented +30% implemented - [X] batch_create_partition - [ ] batch_delete_connection @@ -3135,7 +3156,7 @@ - [ ] batch_get_dev_endpoints - [X] batch_get_jobs - [X] batch_get_partition -- [ ] batch_get_triggers +- [X] batch_get_triggers - [ ] batch_get_workflows - [ ] batch_stop_job_run - [X] batch_update_partition @@ -3162,7 +3183,7 @@ - [ ] create_security_configuration - [ ] create_session - [X] create_table -- [ ] create_trigger +- [X] create_trigger - [ ] create_user_defined_function - [ ] create_workflow - [ ] delete_blueprint @@ -3187,7 +3208,7 @@ - [ ] delete_session - [X] delete_table - [X] delete_table_version -- [ ] delete_trigger +- [X] delete_trigger - [ ] delete_user_defined_function - [ ] delete_workflow - [ ] get_blueprint @@ -3244,8 +3265,8 @@ - [X] get_table_versions - [X] get_tables - [X] get_tags -- [ ] get_trigger -- [ ] get_triggers +- [X] get_trigger +- [X] get_triggers - [ ] get_unfiltered_partition_metadata - [ ] get_unfiltered_partitions_metadata - [ ] get_unfiltered_table_metadata @@ -3272,7 +3293,7 @@ - [ ] list_schemas - [ ] list_sessions - [ ] list_statements -- [ ] list_triggers +- [X] list_triggers - [ ] list_workflows - [ ] put_data_catalog_encryption_settings - [ ] put_resource_policy @@ -3295,12 +3316,12 @@ - [X] start_job_run - [ ] start_ml_evaluation_task_run - [ ] start_ml_labeling_set_generation_task_run -- [ ] start_trigger +- [X] start_trigger - [ ] start_workflow_run - [X] stop_crawler - [ ] stop_crawler_schedule - [ ] stop_session -- [ ] stop_trigger +- [X] stop_trigger - [ ] stop_workflow_run - [X] tag_resource - [X] untag_resource @@ -3482,6 +3503,7 @@ - [ ] list_publishing_destinations - [ ] list_tags_for_resource - [ ] list_threat_intel_sets +- [ ] start_malware_scan - [ ] start_monitoring_members - [ ] stop_monitoring_members - [ ] tag_resource @@ -4863,7 +4885,7 @@ ## pinpoint
-10% implemented +9% implemented - [X] create_app - [ ] create_campaign @@ -4932,6 +4954,9 @@ - [ ] get_journey_date_range_kpi - [ ] get_journey_execution_activity_metrics - [ ] get_journey_execution_metrics +- [ ] get_journey_run_execution_activity_metrics +- [ ] get_journey_run_execution_metrics +- [ ] get_journey_runs - [ ] get_push_template - [ ] get_recommender_configuration - [ ] get_recommender_configurations @@ -6412,6 +6437,98 @@ - [X] verify_email_identity
+## sesv2 +
+10% implemented + +- [ ] batch_get_metric_data +- [ ] create_configuration_set +- [ ] create_configuration_set_event_destination +- [X] create_contact +- [X] create_contact_list +- [ ] create_custom_verification_email_template +- [ ] create_dedicated_ip_pool +- [ ] create_deliverability_test_report +- [ ] create_email_identity +- [ ] create_email_identity_policy +- [ ] create_email_template +- [ ] create_import_job +- [ ] delete_configuration_set +- [ ] delete_configuration_set_event_destination +- [X] delete_contact +- [X] delete_contact_list +- [ ] delete_custom_verification_email_template +- [ ] delete_dedicated_ip_pool +- [ ] delete_email_identity +- [ ] delete_email_identity_policy +- [ ] delete_email_template +- [ ] delete_suppressed_destination +- [ ] get_account +- [ ] get_blacklist_reports +- [ ] get_configuration_set +- [ ] get_configuration_set_event_destinations +- [X] get_contact +- [X] get_contact_list +- [ ] get_custom_verification_email_template +- [ ] get_dedicated_ip +- [ ] get_dedicated_ip_pool +- [ ] get_dedicated_ips +- [ ] get_deliverability_dashboard_options +- [ ] get_deliverability_test_report +- [ ] get_domain_deliverability_campaign +- [ ] get_domain_statistics_report +- [ ] get_email_identity +- [ ] get_email_identity_policies +- [ ] get_email_template +- [ ] get_import_job +- [ ] get_suppressed_destination +- [ ] list_configuration_sets +- [X] list_contact_lists +- [X] list_contacts +- [ ] list_custom_verification_email_templates +- [ ] list_dedicated_ip_pools +- [ ] list_deliverability_test_reports +- [ ] list_domain_deliverability_campaigns +- [ ] list_email_identities +- [ ] list_email_templates +- [ ] list_import_jobs +- [ ] list_recommendations +- [ ] list_suppressed_destinations +- [ ] list_tags_for_resource +- [ ] put_account_dedicated_ip_warmup_attributes +- [ ] put_account_details +- [ ] put_account_sending_attributes +- [ ] put_account_suppression_attributes +- [ ] put_account_vdm_attributes +- [ ] put_configuration_set_delivery_options +- [ ] put_configuration_set_reputation_options +- [ ] put_configuration_set_sending_options +- [ ] put_configuration_set_suppression_options +- [ ] put_configuration_set_tracking_options +- [ ] put_configuration_set_vdm_options +- [ ] put_dedicated_ip_in_pool +- [ ] put_dedicated_ip_warmup_attributes +- [ ] put_deliverability_dashboard_option +- [ ] put_email_identity_configuration_set_attributes +- [ ] put_email_identity_dkim_attributes +- [ ] put_email_identity_dkim_signing_attributes +- [ ] put_email_identity_feedback_attributes +- [ ] put_email_identity_mail_from_attributes +- [ ] put_suppressed_destination +- [ ] send_bulk_email +- [ ] send_custom_verification_email +- [X] send_email +- [ ] tag_resource +- [ ] test_render_email_template +- [ ] untag_resource +- [ ] update_configuration_set_event_destination +- [ ] update_contact +- [ ] update_contact_list +- [ ] update_custom_verification_email_template +- [ ] update_email_identity_policy +- [ ] update_email_template +
+ ## signer
23% implemented @@ -7113,6 +7230,7 @@ - omics - opensearchserverless - opsworkscm +- osis - outposts - panorama - personalize-events @@ -7152,7 +7270,6 @@ - serverlessrepo - servicecatalog - servicecatalog-appregistry -- sesv2 - shield - simspaceweaver - sms diff --git a/docs/docs/services/athena.rst b/docs/docs/services/athena.rst index 32c91addd..20418dde8 100644 --- a/docs/docs/services/athena.rst +++ b/docs/docs/services/athena.rst @@ -28,6 +28,8 @@ athena - [ ] batch_get_named_query - [ ] batch_get_prepared_statement - [ ] batch_get_query_execution +- [ ] cancel_capacity_reservation +- [ ] create_capacity_reservation - [X] create_data_catalog - [X] create_named_query - [ ] create_notebook @@ -43,6 +45,8 @@ athena - [ ] get_calculation_execution - [ ] get_calculation_execution_code - [ ] get_calculation_execution_status +- [ ] get_capacity_assignment_configuration +- [ ] get_capacity_reservation - [X] get_data_catalog - [ ] get_database - [X] get_named_query @@ -104,6 +108,7 @@ athena - [ ] import_notebook - [ ] list_application_dpu_sizes - [ ] list_calculation_executions +- [ ] list_capacity_reservations - [X] list_data_catalogs - [ ] list_databases - [ ] list_engine_versions @@ -117,6 +122,7 @@ athena - [ ] list_table_metadata - [ ] list_tags_for_resource - [X] list_work_groups +- [ ] put_capacity_assignment_configuration - [ ] start_calculation_execution - [X] start_query_execution - [ ] start_session @@ -125,6 +131,7 @@ athena - [ ] tag_resource - [ ] terminate_session - [ ] untag_resource +- [ ] update_capacity_reservation - [ ] update_data_catalog - [ ] update_named_query - [ ] update_notebook diff --git a/docs/docs/services/datasync.rst b/docs/docs/services/datasync.rst index 816741248..94a2e0014 100644 --- a/docs/docs/services/datasync.rst +++ b/docs/docs/services/datasync.rst @@ -25,6 +25,7 @@ datasync |start-h3| Implemented features for this service |end-h3| +- [ ] add_storage_system - [X] cancel_task_execution - [ ] create_agent - [ ] create_location_efs @@ -42,6 +43,7 @@ datasync - [X] delete_location - [X] delete_task - [ ] describe_agent +- [ ] describe_discovery_job - [ ] describe_location_efs - [ ] describe_location_fsx_lustre - [ ] describe_location_fsx_ontap @@ -52,21 +54,32 @@ datasync - [ ] describe_location_object_storage - [ ] describe_location_s3 - [ ] describe_location_smb +- [ ] describe_storage_system +- [ ] describe_storage_system_resource_metrics +- [ ] describe_storage_system_resources - [ ] describe_task - [ ] describe_task_execution +- [ ] generate_recommendations - [ ] list_agents +- [ ] list_discovery_jobs - [ ] list_locations +- [ ] list_storage_systems - [ ] list_tags_for_resource - [ ] list_task_executions - [ ] list_tasks +- [ ] remove_storage_system +- [ ] start_discovery_job - [X] start_task_execution +- [ ] stop_discovery_job - [ ] tag_resource - [ ] untag_resource - [ ] update_agent +- [ ] update_discovery_job - [ ] update_location_hdfs - [ ] update_location_nfs - [ ] update_location_object_storage - [ ] update_location_smb +- [ ] update_storage_system - [X] update_task - [ ] update_task_execution diff --git a/docs/docs/services/emr-containers.rst b/docs/docs/services/emr-containers.rst index 3470ad740..9f48a685f 100644 --- a/docs/docs/services/emr-containers.rst +++ b/docs/docs/services/emr-containers.rst @@ -38,6 +38,7 @@ emr-containers - [ ] describe_job_template - [ ] describe_managed_endpoint - [X] describe_virtual_cluster +- [ ] get_managed_endpoint_session_credentials - [X] list_job_runs - [ ] list_job_templates - [ ] list_managed_endpoints diff --git a/docs/docs/services/glue.rst b/docs/docs/services/glue.rst index cae4e623c..0e0c7407a 100644 --- a/docs/docs/services/glue.rst +++ b/docs/docs/services/glue.rst @@ -37,7 +37,7 @@ glue - [ ] batch_get_dev_endpoints - [X] batch_get_jobs - [X] batch_get_partition -- [ ] batch_get_triggers +- [X] batch_get_triggers - [ ] batch_get_workflows - [ ] batch_stop_job_run - [X] batch_update_partition @@ -68,7 +68,7 @@ glue - [ ] create_security_configuration - [ ] create_session - [X] create_table -- [ ] create_trigger +- [X] create_trigger - [ ] create_user_defined_function - [ ] create_workflow - [ ] delete_blueprint @@ -93,7 +93,7 @@ glue - [ ] delete_session - [X] delete_table - [X] delete_table_version -- [ ] delete_trigger +- [X] delete_trigger - [ ] delete_user_defined_function - [ ] delete_workflow - [ ] get_blueprint @@ -162,8 +162,8 @@ glue - [X] get_table_versions - [X] get_tables - [X] get_tags -- [ ] get_trigger -- [ ] get_triggers +- [X] get_trigger +- [X] get_triggers - [ ] get_unfiltered_partition_metadata - [ ] get_unfiltered_partitions_metadata - [ ] get_unfiltered_table_metadata @@ -190,7 +190,7 @@ glue - [ ] list_schemas - [ ] list_sessions - [ ] list_statements -- [ ] list_triggers +- [X] list_triggers - [ ] list_workflows - [ ] put_data_catalog_encryption_settings - [ ] put_resource_policy @@ -213,12 +213,12 @@ glue - [X] start_job_run - [ ] start_ml_evaluation_task_run - [ ] start_ml_labeling_set_generation_task_run -- [ ] start_trigger +- [X] start_trigger - [ ] start_workflow_run - [X] stop_crawler - [ ] stop_crawler_schedule - [ ] stop_session -- [ ] stop_trigger +- [X] stop_trigger - [ ] stop_workflow_run - [X] tag_resource - [X] untag_resource diff --git a/docs/docs/services/guardduty.rst b/docs/docs/services/guardduty.rst index fb889d0aa..4dbcdd873 100644 --- a/docs/docs/services/guardduty.rst +++ b/docs/docs/services/guardduty.rst @@ -86,6 +86,7 @@ guardduty - [ ] list_publishing_destinations - [ ] list_tags_for_resource - [ ] list_threat_intel_sets +- [ ] start_malware_scan - [ ] start_monitoring_members - [ ] stop_monitoring_members - [ ] tag_resource diff --git a/docs/docs/services/pinpoint.rst b/docs/docs/services/pinpoint.rst index 2671def63..1e0dc01c9 100644 --- a/docs/docs/services/pinpoint.rst +++ b/docs/docs/services/pinpoint.rst @@ -98,6 +98,9 @@ pinpoint - [ ] get_journey_date_range_kpi - [ ] get_journey_execution_activity_metrics - [ ] get_journey_execution_metrics +- [ ] get_journey_run_execution_activity_metrics +- [ ] get_journey_run_execution_metrics +- [ ] get_journey_runs - [ ] get_push_template - [ ] get_recommender_configuration - [ ] get_recommender_configurations diff --git a/docs/docs/services/sesv2.rst b/docs/docs/services/sesv2.rst new file mode 100644 index 000000000..90894285f --- /dev/null +++ b/docs/docs/services/sesv2.rst @@ -0,0 +1,116 @@ +.. _implementedservice_sesv2: + +.. |start-h3| raw:: html + +

+ +.. |end-h3| raw:: html + +

+ +===== +sesv2 +===== + +.. autoclass:: moto.sesv2.models.SESV2Backend + +|start-h3| Example usage |end-h3| + +.. sourcecode:: python + + @mock_sesv2 + def test_sesv2_behaviour: + boto3.client("sesv2") + ... + + + +|start-h3| Implemented features for this service |end-h3| + +- [ ] batch_get_metric_data +- [ ] create_configuration_set +- [ ] create_configuration_set_event_destination +- [X] create_contact +- [X] create_contact_list +- [ ] create_custom_verification_email_template +- [ ] create_dedicated_ip_pool +- [ ] create_deliverability_test_report +- [ ] create_email_identity +- [ ] create_email_identity_policy +- [ ] create_email_template +- [ ] create_import_job +- [ ] delete_configuration_set +- [ ] delete_configuration_set_event_destination +- [X] delete_contact +- [X] delete_contact_list +- [ ] delete_custom_verification_email_template +- [ ] delete_dedicated_ip_pool +- [ ] delete_email_identity +- [ ] delete_email_identity_policy +- [ ] delete_email_template +- [ ] delete_suppressed_destination +- [ ] get_account +- [ ] get_blacklist_reports +- [ ] get_configuration_set +- [ ] get_configuration_set_event_destinations +- [X] get_contact +- [X] get_contact_list +- [ ] get_custom_verification_email_template +- [ ] get_dedicated_ip +- [ ] get_dedicated_ip_pool +- [ ] get_dedicated_ips +- [ ] get_deliverability_dashboard_options +- [ ] get_deliverability_test_report +- [ ] get_domain_deliverability_campaign +- [ ] get_domain_statistics_report +- [ ] get_email_identity +- [ ] get_email_identity_policies +- [ ] get_email_template +- [ ] get_import_job +- [ ] get_suppressed_destination +- [ ] list_configuration_sets +- [X] list_contact_lists +- [X] list_contacts +- [ ] list_custom_verification_email_templates +- [ ] list_dedicated_ip_pools +- [ ] list_deliverability_test_reports +- [ ] list_domain_deliverability_campaigns +- [ ] list_email_identities +- [ ] list_email_templates +- [ ] list_import_jobs +- [ ] list_recommendations +- [ ] list_suppressed_destinations +- [ ] list_tags_for_resource +- [ ] put_account_dedicated_ip_warmup_attributes +- [ ] put_account_details +- [ ] put_account_sending_attributes +- [ ] put_account_suppression_attributes +- [ ] put_account_vdm_attributes +- [ ] put_configuration_set_delivery_options +- [ ] put_configuration_set_reputation_options +- [ ] put_configuration_set_sending_options +- [ ] put_configuration_set_suppression_options +- [ ] put_configuration_set_tracking_options +- [ ] put_configuration_set_vdm_options +- [ ] put_dedicated_ip_in_pool +- [ ] put_dedicated_ip_warmup_attributes +- [ ] put_deliverability_dashboard_option +- [ ] put_email_identity_configuration_set_attributes +- [ ] put_email_identity_dkim_attributes +- [ ] put_email_identity_dkim_signing_attributes +- [ ] put_email_identity_feedback_attributes +- [ ] put_email_identity_mail_from_attributes +- [ ] put_suppressed_destination +- [ ] send_bulk_email +- [ ] send_custom_verification_email +- [X] send_email +- [ ] tag_resource +- [ ] test_render_email_template +- [ ] untag_resource +- [ ] update_configuration_set_event_destination +- [ ] update_contact +- [ ] update_contact_list +- [ ] update_custom_verification_email_template +- [ ] update_email_identity_policy +- [ ] update_email_template + diff --git a/docs/docs/services/ssm.rst b/docs/docs/services/ssm.rst index d60d48c23..986dc6f11 100644 --- a/docs/docs/services/ssm.rst +++ b/docs/docs/services/ssm.rst @@ -109,10 +109,6 @@ ssm - [ ] get_automation_execution - [ ] get_calendar_state - [X] get_command_invocation - - https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_GetCommandInvocation.html - - - [ ] get_connection_status - [ ] get_default_patch_baseline - [ ] get_deployable_patch_snapshot_for_instance @@ -148,7 +144,7 @@ ssm - [ ] list_command_invocations - [X] list_commands - https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_ListCommands.html + Pagination and the Filters-parameter is not yet implemented - [ ] list_compliance_items diff --git a/moto/__init__.py b/moto/__init__.py index e34881101..6932f633d 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -150,6 +150,7 @@ mock_servicequotas = lazy_load( ".servicequotas", "mock_servicequotas", boto3_name="service-quotas" ) mock_ses = lazy_load(".ses", "mock_ses") +mock_sesv2 = lazy_load(".sesv2", "mock_sesv2") mock_servicediscovery = lazy_load(".servicediscovery", "mock_servicediscovery") mock_signer = lazy_load(".signer", "mock_signer") mock_sns = lazy_load(".sns", "mock_sns") diff --git a/moto/backend_index.py b/moto/backend_index.py index 623777c19..8708ee366 100644 --- a/moto/backend_index.py +++ b/moto/backend_index.py @@ -158,6 +158,8 @@ backend_url_patterns = [ ("service-quotas", re.compile("https?://servicequotas\\.(.+)\\.amazonaws\\.com")), ("ses", re.compile("https?://email\\.(.+)\\.amazonaws\\.com")), ("ses", re.compile("https?://ses\\.(.+)\\.amazonaws\\.com")), + ("sesv2", re.compile("https?://ses\\.(.+)\\.amazonaws\\.com")), + ("sesv2", re.compile("https?://email\\.(.+)\\.amazonaws\\.com")), ("signer", re.compile("https?://signer\\.(.+)\\.amazonaws\\.com")), ("sns", re.compile("https?://sns\\.(.+)\\.amazonaws\\.com")), ("sqs", re.compile("https?://(.*\\.)?(queue|sqs)\\.(.*\\.)?amazonaws\\.com")), diff --git a/moto/moto_server/werkzeug_app.py b/moto/moto_server/werkzeug_app.py index 234a74d47..623cf3479 100644 --- a/moto/moto_server/werkzeug_app.py +++ b/moto/moto_server/werkzeug_app.py @@ -153,6 +153,8 @@ class DomainDispatcherApplication: path.startswith("/v20180820/") or "s3-control" in environ["HTTP_HOST"] ): host = "s3control" + elif service == "ses" and path.startswith("/v2/"): + host = "sesv2" else: host = f"{service}.{region}.amazonaws.com" diff --git a/moto/sesv2/__init__.py b/moto/sesv2/__init__.py new file mode 100644 index 000000000..ce12d13ff --- /dev/null +++ b/moto/sesv2/__init__.py @@ -0,0 +1,5 @@ +"""sesv2 module initialization; sets value for base decorator.""" +from .models import sesv2_backends +from ..core.models import base_decorator + +mock_sesv2 = base_decorator(sesv2_backends) diff --git a/moto/sesv2/exceptions.py b/moto/sesv2/exceptions.py new file mode 100644 index 000000000..ffe2bb0fc --- /dev/null +++ b/moto/sesv2/exceptions.py @@ -0,0 +1,8 @@ +from moto.core.exceptions import JsonRESTError + + +class NotFoundException(JsonRESTError): + code = 404 + + def __init__(self, message: str): + super().__init__("NotFoundException", message) diff --git a/moto/sesv2/models.py b/moto/sesv2/models.py new file mode 100644 index 000000000..2ab67f96e --- /dev/null +++ b/moto/sesv2/models.py @@ -0,0 +1,166 @@ +"""SESV2Backend class with methods for supported APIs.""" + +from datetime import datetime as dt +from moto.core import BackendDict, BaseBackend, BaseModel +from ..ses.models import ses_backends, Message, RawMessage +from typing import Dict, List, Any +from .exceptions import NotFoundException +from moto.core.utils import iso_8601_datetime_with_milliseconds + + +class Contact(BaseModel): + def __init__( + self, + contact_list_name: str, + email_address: str, + topic_preferences: List[Dict[str, str]], + unsubscribe_all: bool, + ) -> None: + self.contact_list_name = contact_list_name + self.email_address = email_address + self.topic_default_preferences: List[Dict[str, str]] = [] + self.topic_preferences = topic_preferences + self.unsubscribe_all = unsubscribe_all + self.created_timestamp = iso_8601_datetime_with_milliseconds(dt.utcnow()) + self.last_updated_timestamp = iso_8601_datetime_with_milliseconds(dt.utcnow()) + + @property + def response_object(self) -> Dict[str, Any]: # type: ignore[misc] + return { + "ContactListName": self.contact_list_name, + "EmailAddress": self.email_address, + "TopicDefaultPreferences": self.topic_default_preferences, + "TopicPreferences": self.topic_preferences, + "UnsubscribeAll": self.unsubscribe_all, + "CreatedTimestamp": self.created_timestamp, + "LastUpdatedTimestamp": self.last_updated_timestamp, + } + + +class ContactList(BaseModel): + def __init__( + self, + contact_list_name: str, + description: str, + topics: List[Dict[str, str]], + ) -> None: + self.contact_list_name = contact_list_name + self.description = description + self.topics = topics + self.created_timestamp = iso_8601_datetime_with_milliseconds(dt.utcnow()) + self.last_updated_timestamp = iso_8601_datetime_with_milliseconds(dt.utcnow()) + self.contacts: Dict[str, Contact] = {} + + def create_contact(self, contact_list_name: str, params: Dict[str, Any]) -> None: + email_address = params["EmailAddress"] + topic_preferences = ( + [] if "TopicPreferences" not in params else params["TopicPreferences"] + ) + unsubscribe_all = ( + False if "UnsubscribeAll" not in params else params["UnsubscribeAll"] + ) + new_contact = Contact( + contact_list_name, email_address, topic_preferences, unsubscribe_all + ) + self.contacts[email_address] = new_contact + + def list_contacts(self) -> List[Contact]: + return self.contacts.values() # type: ignore[return-value] + + def get_contact(self, email: str) -> Contact: + if email in self.contacts: + return self.contacts[email] + else: + raise NotFoundException(f"{email} doesn't exist in List.") + + def delete_contact(self, email: str) -> None: + # delete if contact exists, otherwise get_contact will throw appropriate exception + if self.get_contact(email): + del self.contacts[email] + + @property + def response_object(self) -> Dict[str, Any]: # type: ignore[misc] + return { + "ContactListName": self.contact_list_name, + "Description": self.description, + "Topics": self.topics, + "CreatedTimestamp": self.created_timestamp, + "LastUpdatedTimestamp": self.last_updated_timestamp, + } + + +class SESV2Backend(BaseBackend): + """Implementation of SESV2 APIs, piggy back on v1 SES""" + + def __init__(self, region_name: str, account_id: str): + super().__init__(region_name, account_id) + self.contacts: Dict[str, Contact] = {} + self.contacts_lists: Dict[str, ContactList] = {} + + def create_contact_list(self, params: Dict[str, Any]) -> None: + name = params["ContactListName"] + description = params.get("Description") + topics = [] if "Topics" not in params else params["Topics"] + new_list = ContactList(name, str(description), topics) + self.contacts_lists[name] = new_list + + def get_contact_list(self, contact_list_name: str) -> ContactList: + if contact_list_name in self.contacts_lists: + return self.contacts_lists[contact_list_name] + else: + raise NotFoundException( + f"List with name: {contact_list_name} doesn't exist." + ) + + def list_contact_lists(self) -> List[ContactList]: + return self.contacts_lists.values() # type: ignore[return-value] + + def delete_contact_list(self, name: str) -> None: + if name in self.contacts_lists: + del self.contacts_lists[name] + else: + raise NotFoundException(f"List with name: {name} doesn't exist") + + def create_contact(self, contact_list_name: str, params: Dict[str, Any]) -> None: + contact_list = self.get_contact_list(contact_list_name) + contact_list.create_contact(contact_list_name, params) + return + + def get_contact(self, email: str, contact_list_name: str) -> Contact: + contact_list = self.get_contact_list(contact_list_name) + contact = contact_list.get_contact(email) + return contact + + def list_contacts(self, contact_list_name: str) -> List[Contact]: + contact_list = self.get_contact_list(contact_list_name) + contacts = contact_list.list_contacts() + return contacts + + def delete_contact(self, email: str, contact_list_name: str) -> None: + contact_list = self.get_contact_list(contact_list_name) + contact_list.delete_contact(email) + return + + def send_email( + self, source: str, destinations: Dict[str, List[str]], subject: str, body: str + ) -> Message: + v1_backend = ses_backends[self.account_id][self.region_name] + message = v1_backend.send_email( + source=source, + destinations=destinations, + subject=subject, + body=body, + ) + return message + + def send_raw_email( + self, source: str, destinations: List[str], raw_data: str + ) -> RawMessage: + v1_backend = ses_backends[self.account_id][self.region_name] + message = v1_backend.send_raw_email( + source=source, destinations=destinations, raw_data=raw_data + ) + return message + + +sesv2_backends = BackendDict(SESV2Backend, "sesv2") diff --git a/moto/sesv2/responses.py b/moto/sesv2/responses.py new file mode 100644 index 000000000..e0bcbcfea --- /dev/null +++ b/moto/sesv2/responses.py @@ -0,0 +1,102 @@ +"""Handles incoming sesv2 requests, invokes methods, returns responses.""" +import json + +from moto.core.responses import BaseResponse +from .models import sesv2_backends +from ..ses.responses import SEND_EMAIL_RESPONSE +from .models import SESV2Backend +from typing import List, Dict, Any +from urllib.parse import unquote + + +class SESV2Response(BaseResponse): + """Handler for SESV2 requests and responses.""" + + def __init__(self) -> None: + super().__init__(service_name="sesv2") + + @property + def sesv2_backend(self) -> SESV2Backend: + """Return backend instance specific for this region.""" + return sesv2_backends[self.current_account][self.region] + + def send_email(self) -> str: + """Piggy back on functionality from v1 mostly""" + + params = get_params_dict(self.querystring) + from_email_address = params.get("FromEmailAddress") + destination = params.get("Destination") + content = params.get("Content") + if "Raw" in content: + all_destinations: List[str] = [] + if "ToAddresses" in destination: + all_destinations = all_destinations + destination["ToAddresses"] + if "CcAddresses" in destination: + all_destinations = all_destinations + destination["CcAddresses"] + if "BccAddresses" in destination: + all_destinations = all_destinations + destination["BccAddresses"] + message = self.sesv2_backend.send_raw_email( + source=from_email_address, + destinations=all_destinations, + raw_data=content["Raw"]["Data"], + ) + elif "Simple" in content: + message = self.sesv2_backend.send_email( # type: ignore + source=from_email_address, + destinations=destination, + subject=content["Simple"]["Subject"]["Data"], + body=content["Simple"]["Subject"]["Data"], + ) + elif "Template" in content: + raise NotImplementedError("Template functionality not ready") + + # use v1 templates as response same in v1 and v2 + template = self.response_template(SEND_EMAIL_RESPONSE) + return template.render(message=message) + + def create_contact_list(self) -> str: + params = get_params_dict(self.data) + self.sesv2_backend.create_contact_list(params) + return json.dumps({}) + + def get_contact_list(self) -> str: + contact_list_name = self._get_param("ContactListName") + contact_list = self.sesv2_backend.get_contact_list(contact_list_name) + return json.dumps(contact_list.response_object) + + def list_contact_lists(self) -> str: + contact_lists = self.sesv2_backend.list_contact_lists() + return json.dumps(dict(ContactLists=[c.response_object for c in contact_lists])) + + def delete_contact_list(self) -> str: + name = self._get_param("ContactListName") + self.sesv2_backend.delete_contact_list(name) + return json.dumps({}) + + def create_contact(self) -> str: + contact_list_name = self._get_param("ContactListName") + params = get_params_dict(self.data) + self.sesv2_backend.create_contact(contact_list_name, params) + return json.dumps({}) + + def get_contact(self) -> str: + email = unquote(self._get_param("EmailAddress")) + contact_list_name = self._get_param("ContactListName") + contact = self.sesv2_backend.get_contact(email, contact_list_name) + return json.dumps(contact.response_object) + + def list_contacts(self) -> str: + contact_list_name = self._get_param("ContactListName") + contacts = self.sesv2_backend.list_contacts(contact_list_name) + return json.dumps(dict(Contacts=[c.response_object for c in contacts])) + + def delete_contact(self) -> str: + email = self._get_param("EmailAddress") + contact_list_name = self._get_param("ContactListName") + self.sesv2_backend.delete_contact(unquote(email), contact_list_name) + return json.dumps({}) + + +def get_params_dict(odict: Dict[str, Any]) -> Any: + # parsing of these params is nasty, hopefully there is a tidier way + return json.loads(list(dict(odict.items()).keys())[0]) diff --git a/moto/sesv2/urls.py b/moto/sesv2/urls.py new file mode 100644 index 000000000..347b88d96 --- /dev/null +++ b/moto/sesv2/urls.py @@ -0,0 +1,19 @@ +"""sesv2 base URL and path.""" +from .responses import SESV2Response + +url_bases = [ + r"https?://email\.(.+)\.amazonaws\.com", +] + + +response = SESV2Response() + + +url_paths = { + "{0}/v2/email/outbound-emails$": response.dispatch, + "{0}/v2/email/contact-lists/(?P[^/]+)$": response.dispatch, + "{0}/v2/email/contact-lists/(?P[^/]+)/contacts$": response.dispatch, + "{0}/v2/email/contact-lists/(?P[^/]+)/contacts/(?P[^/]+)$": response.dispatch, + "{0}/v2/email/contact-lists$": response.dispatch, + "{0}/v2/.*$": response.dispatch, +} diff --git a/scripts/implementation_coverage.py b/scripts/implementation_coverage.py index 16dc65007..0aa3d50a0 100755 --- a/scripts/implementation_coverage.py +++ b/scripts/implementation_coverage.py @@ -61,6 +61,7 @@ def calculate_extended_implementation_coverage(): operation_names = [ xform_name(op) for op in real_client.meta.service_model.operation_names ] + for op in operation_names: if moto_client and op in dir(moto_client): implemented[op] = getattr(moto_client, op) diff --git a/tests/test_sesv2/__init__.py b/tests/test_sesv2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_sesv2/test_server.py b/tests/test_sesv2/test_server.py new file mode 100644 index 000000000..cc869309a --- /dev/null +++ b/tests/test_sesv2/test_server.py @@ -0,0 +1,13 @@ +"""Test different server responses.""" + +import moto.server as server + + +def test_sesv2_list(): + backend = server.create_backend_app("sesv2") + test_client = backend.test_client() + + resp = test_client.get("/v2/email/contact-lists") + + assert resp.status_code == 200 + assert resp.data == b'{"ContactLists": []}' diff --git a/tests/test_sesv2/test_sesv2.py b/tests/test_sesv2/test_sesv2.py new file mode 100644 index 000000000..cf608af47 --- /dev/null +++ b/tests/test_sesv2/test_sesv2.py @@ -0,0 +1,323 @@ +import boto3 +from botocore.exceptions import ClientError +import pytest +from moto import mock_sesv2, mock_ses +from ..test_ses.test_ses_boto3 import get_raw_email + + +@pytest.fixture(scope="function") +def ses_v1(): + """Use this for API calls which exist in v1 but not in v2""" + with mock_ses(): + yield boto3.client("ses", region_name="us-east-1") + + +@mock_sesv2 +def test_send_email(ses_v1): # pylint: disable=redefined-outer-name + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + kwargs = dict( + FromEmailAddress="test@example.com", + Destination={ + "ToAddresses": ["test_to@example.com"], + "CcAddresses": ["test_cc@example.com"], + "BccAddresses": ["test_bcc@example.com"], + }, + Content={ + "Simple": { + "Subject": {"Data": "test subject"}, + "Body": {"Text": {"Data": "test body"}}, + }, + }, + ) + + # Execute + with pytest.raises(ClientError) as e: + conn.send_email(**kwargs) + assert e.value.response["Error"]["Code"] == "MessageRejected" + + ses_v1.verify_domain_identity(Domain="example.com") + conn.send_email(**kwargs) + send_quota = ses_v1.get_send_quota() + + # Verify + sent_count = int(send_quota["SentLast24Hours"]) + assert sent_count == 3 + + +@mock_sesv2 +def test_send_raw_email(ses_v1): # pylint: disable=redefined-outer-name + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + message = get_raw_email() + destination = { + "ToAddresses": [x.strip() for x in message["To"].split(",")], + } + kwargs = dict( + FromEmailAddress=message["From"], + Destination=destination, + Content={"Raw": {"Data": message.as_bytes()}}, + ) + + # Execute + ses_v1.verify_email_identity(EmailAddress="test@example.com") + conn.send_email(**kwargs) + + # Verify + send_quota = ses_v1.get_send_quota() + sent_count = int(send_quota["SentLast24Hours"]) + assert sent_count == 2 + + +@mock_sesv2 +def test_create_contact_list(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + + # Execute + conn.create_contact_list( + ContactListName=contact_list_name, + ) + result = conn.list_contact_lists() + + # Verify + assert len(result["ContactLists"]) == 1 + assert result["ContactLists"][0]["ContactListName"] == contact_list_name + + +@mock_sesv2 +def test_list_contact_lists(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + + # Execute + result = conn.list_contact_lists() + + # Verify + assert result["ContactLists"] == [] + + +@mock_sesv2 +def test_get_contact_list(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + + # Execute + with pytest.raises(ClientError) as e: + conn.get_contact_list(ContactListName=contact_list_name) + assert e.value.response["Error"]["Code"] == "NotFoundException" + assert ( + e.value.response["Error"]["Message"] + == f"List with name: {contact_list_name} doesn't exist." + ) + + conn.create_contact_list( + ContactListName=contact_list_name, + ) + result = conn.get_contact_list(ContactListName=contact_list_name) + + # Verify + assert result["ContactListName"] == contact_list_name + + +@mock_sesv2 +def test_delete_contact_list(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + + # Execute + with pytest.raises(ClientError) as e: + conn.delete_contact_list(ContactListName=contact_list_name) + assert e.value.response["Error"]["Code"] == "NotFoundException" + conn.create_contact_list( + ContactListName=contact_list_name, + ) + result = conn.list_contact_lists() + assert len(result["ContactLists"]) == 1 + conn.delete_contact_list( + ContactListName=contact_list_name, + ) + result = conn.list_contact_lists() + + # Verify + assert len(result["ContactLists"]) == 0 + + +@mock_sesv2 +def test_list_contacts(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + conn.create_contact_list( + ContactListName=contact_list_name, + ) + + # Execute + result = conn.list_contacts(ContactListName=contact_list_name) + + # Verify + assert result["Contacts"] == [] + + +@mock_sesv2 +def test_create_contact_no_contact_list(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + email = "test@example.com" + + # Execute + with pytest.raises(ClientError) as e: + conn.create_contact( + ContactListName=contact_list_name, + EmailAddress=email, + ) + + # Verify + assert e.value.response["Error"]["Code"] == "NotFoundException" + assert ( + e.value.response["Error"]["Message"] + == f"List with name: {contact_list_name} doesn't exist." + ) + + +@mock_sesv2 +def test_create_contact(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + email = "test@example.com" + conn.create_contact_list( + ContactListName=contact_list_name, + ) + + # Execute + conn.create_contact( + ContactListName=contact_list_name, + EmailAddress=email, + ) + + result = conn.list_contacts(ContactListName=contact_list_name) + + # Verify + assert len(result["Contacts"]) == 1 + assert result["Contacts"][0]["EmailAddress"] == email + + +@mock_sesv2 +def test_get_contact_no_contact_list(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + email = "test@example.com" + + # Execute + with pytest.raises(ClientError) as e: + conn.get_contact(ContactListName=contact_list_name, EmailAddress=email) + + # Verify + assert e.value.response["Error"]["Code"] == "NotFoundException" + assert ( + e.value.response["Error"]["Message"] + == f"List with name: {contact_list_name} doesn't exist." + ) + + +@mock_sesv2 +def test_get_contact(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + email = "test@example.com" + conn.create_contact_list( + ContactListName=contact_list_name, + ) + # Execute + + conn.create_contact( + ContactListName=contact_list_name, + EmailAddress=email, + ) + result = conn.get_contact(ContactListName=contact_list_name, EmailAddress=email) + + # Verify + assert result["ContactListName"] == contact_list_name + assert result["EmailAddress"] == email + + +@mock_sesv2 +def test_get_contact_no_contact(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + email = "test@example.com" + conn.create_contact_list( + ContactListName=contact_list_name, + ) + # Execute + with pytest.raises(ClientError) as e: + conn.get_contact(ContactListName=contact_list_name, EmailAddress=email) + + # Verify + assert e.value.response["Error"]["Code"] == "NotFoundException" + assert e.value.response["Error"]["Message"] == f"{email} doesn't exist in List." + + +@mock_sesv2 +def test_delete_contact_no_contact_list(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + email = "test@example.com" + + # Execute + with pytest.raises(ClientError) as e: + conn.delete_contact(ContactListName=contact_list_name, EmailAddress=email) + + # Verify + assert e.value.response["Error"]["Code"] == "NotFoundException" + assert ( + e.value.response["Error"]["Message"] + == f"List with name: {contact_list_name} doesn't exist." + ) + + +@mock_sesv2 +def test_delete_contact_no_contact(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + email = "test@example.com" + + # Execute + conn.create_contact_list(ContactListName=contact_list_name) + + with pytest.raises(ClientError) as e: + conn.delete_contact(ContactListName=contact_list_name, EmailAddress=email) + + # Verify + assert e.value.response["Error"]["Code"] == "NotFoundException" + assert e.value.response["Error"]["Message"] == f"{email} doesn't exist in List." + + +@mock_sesv2 +def test_delete_contact(): + # Setup + conn = boto3.client("sesv2", region_name="us-east-1") + contact_list_name = "test2" + email = "test@example.com" + + # Execute + conn.create_contact_list(ContactListName=contact_list_name) + conn.create_contact(ContactListName=contact_list_name, EmailAddress=email) + result = conn.list_contacts(ContactListName=contact_list_name) + assert len(result["Contacts"]) == 1 + + conn.delete_contact(ContactListName=contact_list_name, EmailAddress=email) + result = conn.list_contacts(ContactListName=contact_list_name) + + # Verify + assert len(result["Contacts"]) == 0