From 189e0abc9d9206afb236ba2e70dfe6d783e90ff9 Mon Sep 17 00:00:00 2001 From: Manish Ram Date: Tue, 3 Nov 2026 22:19:58 -0800 Subject: [PATCH] fix(parser): accept SES events without common headers or invocation type --- .../utilities/data_classes/ses_event.py | 24 +++++----- .../utilities/parser/models/ses.py | 15 ++++--- .../required_dependencies/test_ses_event.py | 21 +++++++++ tests/unit/parser/_pydantic/test_ses.py | 44 +++++++++++++++++++ 4 files changed, 85 insertions(+), 19 deletions(-) diff --git a/aws_lambda_powertools/utilities/data_classes/ses_event.py b/aws_lambda_powertools/utilities/data_classes/ses_event.py index 2e49f63da14..127d96b61ad 100644 --- a/aws_lambda_powertools/utilities/data_classes/ses_event.py +++ b/aws_lambda_powertools/utilities/data_classes/ses_event.py @@ -20,35 +20,35 @@ def value(self) -> str: class SESMailCommonHeaders(DictWrapper): @property - def return_path(self) -> str: + def return_path(self) -> str | None: """The values in the Return-Path header of the email.""" - return self["returnPath"] + return self.get("returnPath") @property def get_from(self) -> list[str]: """The values in the From header of the email.""" # Note: this name conflicts with existing python builtins - return self["from"] + return self.get("from") or [] @property - def date(self) -> str: + def date(self) -> str | None: """The date and time when Amazon SES received the message.""" - return self["date"] + return self.get("date") @property def to(self) -> list[str]: """The values in the To header of the email.""" - return self["to"] + return self.get("to") or [] @property - def message_id(self) -> str: + def message_id(self) -> str | None: """The ID of the original message.""" - return str(self["messageId"]) + return self.get("messageId") @property - def subject(self) -> str: + def subject(self) -> str | None: """The value of the Subject header for the email.""" - return str(self["subject"]) + return self.get("subject") @property def cc(self) -> list[str]: @@ -148,10 +148,10 @@ def function_arn(self) -> str: return self["functionArn"] @property - def invocation_type(self) -> str: + def invocation_type(self) -> str | None: """String that contains the invocation type of the Lambda function. Possible values are RequestResponse and Event. Present only for the Lambda action type.""" - return self["invocationType"] + return self.get("invocationType") class SESReceipt(DictWrapper): diff --git a/aws_lambda_powertools/utilities/parser/models/ses.py b/aws_lambda_powertools/utilities/parser/models/ses.py index 28d2da9e7ac..5ff3b3403da 100644 --- a/aws_lambda_powertools/utilities/parser/models/ses.py +++ b/aws_lambda_powertools/utilities/parser/models/ses.py @@ -11,7 +11,7 @@ class SesReceiptVerdict(BaseModel): class SesReceiptAction(BaseModel): type: Literal["Lambda"] # noqa A003,VNE003 - invocationType: Literal["Event"] + invocationType: Literal["Event", "RequestResponse"] | None = None functionArn: str @@ -32,8 +32,9 @@ class SesMailHeaders(BaseModel): class SesMailCommonHeaders(BaseModel): - header_from: list[str] = Field(..., alias="from") - to: list[str] + # SES sends each common header only when the email has that header + header_from: list[str] | None = Field(None, alias="from") + to: list[str] | None = None cc: list[str] | None = None bcc: list[str] | None = None sender: list[str] | None = None @@ -42,10 +43,10 @@ class SesMailCommonHeaders(BaseModel): validation_alias=AliasChoices("replyTo", "reply-to", "reply_to"), serialization_alias="replyTo", ) - returnPath: str - messageId: str - date: str - subject: str + returnPath: str | None = None + messageId: str | None = None + date: str | None = None + subject: str | None = None class SesMail(BaseModel): diff --git a/tests/unit/data_classes/required_dependencies/test_ses_event.py b/tests/unit/data_classes/required_dependencies/test_ses_event.py index 374c5f4884e..08986438a6f 100644 --- a/tests/unit/data_classes/required_dependencies/test_ses_event.py +++ b/tests/unit/data_classes/required_dependencies/test_ses_event.py @@ -55,3 +55,24 @@ def test_ses_trigger_event(): assert parsed_event.record.raw_event == raw_event["Records"][0] assert parsed_event.mail.raw_event == raw_event["Records"][0]["ses"]["mail"] assert parsed_event.receipt.raw_event == raw_event["Records"][0]["ses"]["receipt"] + + +def test_ses_trigger_event_without_common_headers_or_invocation_type(): + # GIVEN an SES event for an email without any common headers and a Lambda action without an invocation type + raw_event = load_event("sesEvent.json") + raw_event["Records"][0]["ses"]["mail"]["commonHeaders"] = {} + del raw_event["Records"][0]["ses"]["receipt"]["action"]["invocationType"] + + # WHEN reading the common headers and the action + parsed_event = SESEvent(raw_event) + common_headers = parsed_event.mail.common_headers + action = parsed_event.receipt.action + + # THEN the missing headers are empty instead of raising KeyError + assert common_headers.return_path is None + assert common_headers.get_from == [] + assert common_headers.date is None + assert common_headers.to == [] + assert common_headers.message_id is None + assert common_headers.subject is None + assert action.invocation_type is None diff --git a/tests/unit/parser/_pydantic/test_ses.py b/tests/unit/parser/_pydantic/test_ses.py index e07617cdc3f..51daef0937b 100644 --- a/tests/unit/parser/_pydantic/test_ses.py +++ b/tests/unit/parser/_pydantic/test_ses.py @@ -1,3 +1,5 @@ +import pytest + from aws_lambda_powertools.utilities.parser.models import SesModel, SesRecordModel from tests.functional.utils import load_event @@ -117,3 +119,45 @@ def test_ses_trigger_event_with_optional_common_headers(): assert common_headers.bcc == ["bcc@example.com"] assert common_headers.sender == ["sender@example.com"] assert common_headers.reply_to == ["reply@example.com"] + + +@pytest.mark.parametrize("invocation_type", ["Event", "RequestResponse"]) +def test_ses_trigger_event_with_invocation_type(invocation_type): + # GIVEN an SES event from a Lambda action with either documented invocation type + raw_event = load_event("sesEvent.json") + raw_event["Records"][0]["ses"]["receipt"]["action"]["invocationType"] = invocation_type + + # WHEN parsing the event + action = SesModel(**raw_event).Records[0].ses.receipt.action + + # THEN the invocation type is kept + assert action.invocationType == invocation_type + + +def test_ses_trigger_event_without_invocation_type(): + # GIVEN an SES event whose Lambda action has no invocation type + raw_event = load_event("sesEvent.json") + del raw_event["Records"][0]["ses"]["receipt"]["action"]["invocationType"] + + # WHEN parsing the event + action = SesModel(**raw_event).Records[0].ses.receipt.action + + # THEN the invocation type is None + assert action.invocationType is None + + +def test_ses_trigger_event_without_common_headers(): + # GIVEN an SES event for an email without any common headers (SES only sends the ones the email has) + raw_event = load_event("sesEvent.json") + raw_event["Records"][0]["ses"]["mail"]["commonHeaders"] = {} + + # WHEN parsing the event + common_headers = SesModel(**raw_event).Records[0].ses.mail.commonHeaders + + # THEN every common header is None + assert common_headers.header_from is None + assert common_headers.to is None + assert common_headers.returnPath is None + assert common_headers.messageId is None + assert common_headers.date is None + assert common_headers.subject is None