Repository: getmoto/moto
Pull request #6405 (2023-06-14 09:47:43)

SESv2: send_email(): raw emails are send as JSON
Before we would use query-parameters to get the arguments - but the query-parameters parser sometimes get confused if the body contains a `=`. Considering the parameters are send as JSON, we can just parse the body directly.

The actual message is base64 encoded, so we now decode the message before handing it off to the backend.

Because the message is now in proper email format, we can read and store the `source` and `destinations` of the email (with a few modifications)


Patch:
diff --git a/.github/workflows/payment_notification.yml b/.github/workflows/payment_notification.yml
index 7de8eb946..d12770498 100644
--- a/.github/workflows/payment_notification.yml
+++ b/.github/workflows/payment_notification.yml
@@ -7,9 +7,12 @@ on:
     types: [ labeled ]
 
 jobs:
-  test:
+  leave_comment:
     if: ${{ github.repository == 'getmoto/moto' && github.event.label.name == 'payment-approved' }}
     runs-on: ubuntu-latest
+    permissions:
+      contents: write
+      pull-requests: write
 
     steps:
     - uses: actions/github-script@v5
diff --git a/moto/ses/models.py b/moto/ses/models.py
index f91328fa9..ffc8bf6e5 100644
--- a/moto/ses/models.py
+++ b/moto/ses/models.py
@@ -32,7 +32,6 @@ RECIPIENT_LIMIT = 50
 
 
 class SESFeedback(BaseModel):
-
     BOUNCE = "Bounce"
     COMPLAINT = "Complaint"
     DELIVERY = "Delivery"
@@ -340,23 +339,20 @@ class SESBackend(BaseBackend):
                     f"Did not have authority to send from email {source_email_address}"
                 )
 
-        recipient_count = len(destinations)
         message = email.message_from_string(raw_data)
         if source is None:
             if message["from"] is None:
                 raise MessageRejectedError("Source not specified")
 
-            _, source_email_address = parseaddr(message["from"])
-            if not self._is_verified_address(source_email_address):
+            _, source = parseaddr(message["from"])
+            if not self._is_verified_address(source):
                 raise MessageRejectedError(
-                    f"Did not have authority to send from email {source_email_address}"
+                    f"Did not have authority to send from email {source}"
                 )
 
         for header in "TO", "CC", "BCC":
-            recipient_count += sum(
-                d.strip() and 1 or 0 for d in message.get(header, "").split(",")
-            )
-        if recipient_count > RECIPIENT_LIMIT:
+            destinations += [d.strip() for d in message.get(header, "").split(",") if d]
+        if len(destinations) > RECIPIENT_LIMIT:
             raise MessageRejectedError("Too many recipients.")
         for address in [addr for addr in [source, *destinations] if addr is not None]:
             msg = is_valid_address(address)
@@ -365,7 +361,7 @@ class SESBackend(BaseBackend):
 
         self.__process_sns_feedback__(source, destinations)
 
-        self.sent_message_count += recipient_count
+        self.sent_message_count += len(destinations)
         message_id = get_random_message_id()
         raw_message = RawMessage(message_id, source, destinations, raw_data)
         self.sent_messages.append(raw_message)
@@ -416,7 +412,6 @@ class SESBackend(BaseBackend):
     def create_configuration_set_event_destination(
         self, configuration_set_name: str, event_destination: Dict[str, Any]
     ) -> None:
-
         if self.config_set.get(configuration_set_name) is None:
             raise ConfigurationSetDoesNotExist("Invalid Configuration Set Name.")
 
diff --git a/moto/ses/responses.py b/moto/ses/responses.py
index 6e1d9694a..03fe9a2b0 100644
--- a/moto/ses/responses.py
+++ b/moto/ses/responses.py
@@ -181,7 +181,6 @@ class EmailResponse(BaseResponse):
         return template.render()
 
     def set_identity_notification_topic(self) -> str:
-
         identity = self.querystring.get("Identity")[0]  # type: ignore
         not_type = self.querystring.get("NotificationType")[0]  # type: ignore
         sns_topic = self.querystring.get("SnsTopic")  # type: ignore
@@ -212,7 +211,6 @@ class EmailResponse(BaseResponse):
         return template.render(name=configuration_set_name)
 
     def create_configuration_set_event_destination(self) -> str:
-
         configuration_set_name = self._get_param("ConfigurationSetName")  # type: ignore
         is_configuration_event_enabled = self.querystring.get(
             "EventDestination.Enabled"
diff --git a/moto/sesv2/responses.py b/moto/sesv2/responses.py
index e0bcbcfea..3eca20ba1 100644
--- a/moto/sesv2/responses.py
+++ b/moto/sesv2/responses.py
@@ -1,4 +1,5 @@
 """Handles incoming sesv2 requests, invokes methods, returns responses."""
+import base64
 import json
 
 from moto.core.responses import BaseResponse
@@ -23,9 +24,9 @@ class SESV2Response(BaseResponse):
     def send_email(self) -> str:
         """Piggy back on functionality from v1 mostly"""
 
-        params = get_params_dict(self.querystring)
+        params = json.loads(self.body)
         from_email_address = params.get("FromEmailAddress")
-        destination = params.get("Destination")
+        destination = params.get("Destination", {})
         content = params.get("Content")
         if "Raw" in content:
             all_destinations: List[str] = []
@@ -38,7 +39,7 @@ class SESV2Response(BaseResponse):
             message = self.sesv2_backend.send_raw_email(
                 source=from_email_address,
                 destinations=all_destinations,
-                raw_data=content["Raw"]["Data"],
+                raw_data=base64.b64decode(content["Raw"]["Data"]).decode("utf-8"),
             )
         elif "Simple" in content:
             message = self.sesv2_backend.send_email(  # type: ignore


Test patch:
diff --git a/tests/test_sesv2/test_sesv2.py b/tests/test_sesv2/test_sesv2.py
index cf608af47..2cab6638f 100644
--- a/tests/test_sesv2/test_sesv2.py
+++ b/tests/test_sesv2/test_sesv2.py
@@ -1,7 +1,9 @@
 import boto3
 from botocore.exceptions import ClientError
 import pytest
-from moto import mock_sesv2, mock_ses
+from moto import mock_sesv2, mock_ses, settings
+from moto.ses.models import ses_backends, RawMessage
+from tests import DEFAULT_ACCOUNT_ID
 from ..test_ses.test_ses_boto3 import get_raw_email
 
 
@@ -63,11 +65,42 @@ def test_send_raw_email(ses_v1):  # pylint: disable=redefined-outer-name
     ses_v1.verify_email_identity(EmailAddress="test@example.com")
     conn.send_email(**kwargs)
 
+    # Verify
+    send_quota = ses_v1.get_send_quota()
+    # 2 destinations in the message, two in the 'Destination'-argument
+    assert int(send_quota["SentLast24Hours"]) == 4
+
+
+@mock_sesv2
+def test_send_raw_email__with_specific_message(
+    ses_v1,
+):  # pylint: disable=redefined-outer-name
+    # Setup
+    conn = boto3.client("sesv2", region_name="us-east-1")
+    message = get_raw_email()
+    # This particular message means that our base64-encoded body contains a '='
+    # Testing this to ensure that we parse the body as JSON, not as a query-dict
+    message["Subject"] = "Test-2"
+    kwargs = dict(
+        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
 
+    if not settings.TEST_SERVER_MODE:
+        backend = ses_backends[DEFAULT_ACCOUNT_ID]["us-east-1"]
+        msg: RawMessage = backend.sent_messages[0]
+        assert message.as_bytes() == msg.raw_data.encode("utf-8")
+        assert msg.source == "test@example.com"
+        assert msg.destinations == ["to@example.com", "foo@example.com"]
+
 
 @mock_sesv2
 def test_create_contact_list():