From f6daddcf80b9bd2eca9550c56f30ff285b0a39c9 Mon Sep 17 00:00:00 2001 From: Mariot Tsitoara Date: Tue, 21 Jul 2026 10:54:46 +0200 Subject: [PATCH 1/4] feat(smtp): adds custom headers (#295) --- email-smtp/README.md | 6 +- .../contracts/craft_email/contract.py | 6 ++ .../email_smtp/injector/openaev_email_smtp.py | 1 + email-smtp/email_smtp/models/__init__.py | 2 + email-smtp/email_smtp/models/exceptions.py | 6 ++ .../email_smtp/services/email_client.py | 3 + email-smtp/email_smtp/services/utils.py | 48 ++++++++++++++++ .../tests/craft_email/test_craft_email.py | 57 ++++++++++++++++++- .../tests/email_client/test_email_client.py | 55 ++++++++++++++++++ .../tests/email_payload/test_email_payload.py | 49 ++++++++++++++++ 10 files changed, 230 insertions(+), 3 deletions(-) diff --git a/email-smtp/README.md b/email-smtp/README.md index c0fa9580..e0d158e8 100644 --- a/email-smtp/README.md +++ b/email-smtp/README.md @@ -118,7 +118,8 @@ injector: Injects carry the message-specific fields: `from`, optional `mail_from` (SMTP envelope sender), optional `reply_to`, `to`, `subject`, `body`, optional -`cc` and `bcc` (comma-separated email lists), and SMTP fields: +`cc` and `bcc` (comma-separated email lists), optional `custom_headers` (one +`name: value` header per line), and SMTP fields: `smtp_hostname`, `smtp_port`, `smtp_use_tls`, `smtp_username`, `smtp_password`. They can also carry optional attachments through the contract attachment field. @@ -142,6 +143,7 @@ The injector registers a single contract labelled "Email (SMTP) - Craft email" i | Bcc | `bcc` | No | Comma-separated list of Bcc recipients. | | Subject | `subject` | Yes | Subject of the email. | | Body | `body` | Yes | Plain-text body of the email. | +| Custom Headers| `custom_headers`| No | One custom header per line (`name: value`); unsafe headers are rejected. | | Attachments | `attachments` | No | Inject documents sent as email attachments. | ## Target selection @@ -159,7 +161,7 @@ flowchart LR I -->|status + message| O ``` -On each job the injector acknowledges reception, builds the MIME message (adding Cc, Bcc and any attached inject +On each job the injector acknowledges reception, validates optional custom headers, builds the MIME message (adding Cc, Bcc, custom headers and any attached inject documents), connects to the SMTP server defined in the inject (with STARTTLS and authentication when configured), sends the email, and returns a success or error status to OpenAEV. diff --git a/email-smtp/email_smtp/contracts/craft_email/contract.py b/email-smtp/email_smtp/contracts/craft_email/contract.py index c67b0bae..92956ff2 100644 --- a/email-smtp/email_smtp/contracts/craft_email/contract.py +++ b/email-smtp/email_smtp/contracts/craft_email/contract.py @@ -48,6 +48,12 @@ def contract_with_specific_fields(cls) -> List[ContractElement]: .optional(ContractText(key="bcc", label="Bcc (comma-separated emails)")) .mandatory(ContractText(key="subject", label="Subject")) .mandatory(ContractTextArea(key="body", label="Body")) + .optional( + ContractTextArea( + key="custom_headers", + label="Custom Headers (one per line: name: value)", + ) + ) .optional(attachment_field) .build_fields() ) diff --git a/email-smtp/email_smtp/injector/openaev_email_smtp.py b/email-smtp/email_smtp/injector/openaev_email_smtp.py index 4bd13a15..1a33217d 100644 --- a/email-smtp/email_smtp/injector/openaev_email_smtp.py +++ b/email-smtp/email_smtp/injector/openaev_email_smtp.py @@ -59,6 +59,7 @@ def execute(self, data: Dict) -> ExecutionResult: bcc_emails=payload["bcc"], subject=payload["subject"], body=payload["body"], + custom_headers=payload["custom_headers"], attachments=attachments, ) if result.success: diff --git a/email-smtp/email_smtp/models/__init__.py b/email-smtp/email_smtp/models/__init__.py index 798b366f..0e0a7250 100644 --- a/email-smtp/email_smtp/models/__init__.py +++ b/email-smtp/email_smtp/models/__init__.py @@ -1,6 +1,7 @@ from email_smtp.models.configs import ConfigLoader, InjectorConfigOverride from email_smtp.models.exceptions import ( AttachmentDownloadError, + CustomHeaderValidationError, EmailInjectorError, InvalidContractError, MissingRequiredFieldError, @@ -13,4 +14,5 @@ "InvalidContractError", "MissingRequiredFieldError", "AttachmentDownloadError", + "CustomHeaderValidationError", ] diff --git a/email-smtp/email_smtp/models/exceptions.py b/email-smtp/email_smtp/models/exceptions.py index 300a554f..333828da 100644 --- a/email-smtp/email_smtp/models/exceptions.py +++ b/email-smtp/email_smtp/models/exceptions.py @@ -20,3 +20,9 @@ class AttachmentDownloadError(EmailInjectorError): """Raised when an inject attachment cannot be downloaded.""" pass + + +class CustomHeaderValidationError(EmailInjectorError, ValueError): + """Raised when a custom email header is malformed or unsafe.""" + + pass diff --git a/email-smtp/email_smtp/services/email_client.py b/email-smtp/email_smtp/services/email_client.py index 4d084e87..b3bec058 100644 --- a/email-smtp/email_smtp/services/email_client.py +++ b/email-smtp/email_smtp/services/email_client.py @@ -31,6 +31,7 @@ def send_email( bcc_emails: list[str], subject: str, body: str, + custom_headers: list[tuple[str, str]] | None = None, attachments: list[tuple[str, bytes]] | None = None, ) -> ExecutionResult: try: @@ -48,6 +49,8 @@ def send_email( if cc_emails: msg["Cc"] = ", ".join(cc_emails) msg["Subject"] = subject + for header_name, header_value in custom_headers or []: + msg[header_name] = header_value msg.attach(MIMEText(body, "plain")) for attachment_filename, attachment_content in attachments or []: attachment_part = MIMEApplication(attachment_content) diff --git a/email-smtp/email_smtp/services/utils.py b/email-smtp/email_smtp/services/utils.py index f3bcfbc8..e73292d6 100644 --- a/email-smtp/email_smtp/services/utils.py +++ b/email-smtp/email_smtp/services/utils.py @@ -1,5 +1,10 @@ +import re from typing import Dict, List, Optional +from email_smtp.models.exceptions import CustomHeaderValidationError + +HEADER_NAME_PATTERN = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$") + class EmailPayloadBuilder: @staticmethod @@ -25,6 +30,46 @@ def parse_bool(value: bool | str | None) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} return False + @staticmethod + def parse_custom_headers(value: str | None) -> List[tuple[str, str]]: + if not value: + return [] + + headers: List[tuple[str, str]] = [] + for line_number, raw_line in enumerate(value.splitlines(), start=1): + line = raw_line.strip() + if not line: + continue + if ":" not in line: + raise CustomHeaderValidationError( + f"Invalid custom header at line {line_number}: expected 'name: value'" + ) + + header_name, header_value = line.split(":", 1) + header_name = header_name.strip() + header_value = header_value.strip() + + if not header_name: + raise CustomHeaderValidationError( + f"Invalid custom header at line {line_number}: header name is required" + ) + if not HEADER_NAME_PATTERN.fullmatch(header_name): + raise CustomHeaderValidationError( + f"Invalid custom header at line {line_number}: unsafe header name" + ) + if not header_value: + raise CustomHeaderValidationError( + f"Invalid custom header at line {line_number}: header value is required" + ) + if any(ord(char) < 32 or ord(char) == 127 for char in header_value): + raise CustomHeaderValidationError( + f"Invalid custom header at line {line_number}: unsafe header value" + ) + + headers.append((header_name, header_value)) + + return headers + @staticmethod def build(content: Dict) -> Dict: return { @@ -46,4 +91,7 @@ def build(content: Dict) -> Dict: "bcc": EmailPayloadBuilder.parse_recipients(content.get("bcc")), "subject": content["subject"], "body": content["body"], + "custom_headers": EmailPayloadBuilder.parse_custom_headers( + content.get("custom_headers") + ), } diff --git a/email-smtp/tests/craft_email/test_craft_email.py b/email-smtp/tests/craft_email/test_craft_email.py index 54c30fc1..e131a269 100644 --- a/email-smtp/tests/craft_email/test_craft_email.py +++ b/email-smtp/tests/craft_email/test_craft_email.py @@ -2,7 +2,11 @@ import pytest from email_smtp.contracts import EmailContractId -from email_smtp.models.exceptions import InvalidContractError, MissingRequiredFieldError +from email_smtp.models.exceptions import ( + CustomHeaderValidationError, + InvalidContractError, + MissingRequiredFieldError, +) from email_smtp.services.email_client import ExecutionResult CONTRACT_ID = EmailContractId.CRAFT_EMAIL @@ -63,6 +67,30 @@ def test_execute_sends_email_with_downloaded_attachments( ] +@patch("email_smtp.injector.openaev_email_smtp.EmailClient.send_email") +def test_execute_parses_and_forwards_custom_headers( + mock_send_email, email_smtp_injector +): + mock_send_email.return_value = ExecutionResult(success=True, message="sent") + content = { + "smtp_hostname": "smtp.example.com", + "smtp_port": "25", + "from": "sender@example.com", + "to": "recipient@example.com", + "subject": "Subject", + "body": "Body", + "custom_headers": "X-OpenAEV-Test: true\nX-Trace-ID: abc-123", + } + + result = email_smtp_injector.execute(_data(content=content)) + + assert result.success + assert mock_send_email.call_args.kwargs["custom_headers"] == [ + ("X-OpenAEV-Test", "true"), + ("X-Trace-ID", "abc-123"), + ] + + def test_extract_attachments_requires_document_id(email_smtp_injector): with pytest.raises(MissingRequiredFieldError, match="missing a document_id"): email_smtp_injector._extract_attachments( @@ -77,6 +105,33 @@ def test_extract_attachments_requires_document_id(email_smtp_injector): ) +@pytest.mark.parametrize( + "custom_headers,expected_message", + [ + ("bad header: true", "unsafe header name"), + ("X-OpenAEV-Test:", "header value is required"), + ], +) +@patch("email_smtp.injector.openaev_email_smtp.EmailClient.send_email") +def test_execute_rejects_unsafe_custom_headers( + mock_send_email, email_smtp_injector, custom_headers, expected_message +): + content = { + "smtp_hostname": "smtp.example.com", + "smtp_port": "25", + "from": "sender@example.com", + "to": "recipient@example.com", + "subject": "Subject", + "body": "Body", + "custom_headers": custom_headers, + } + + with pytest.raises(CustomHeaderValidationError, match=expected_message): + email_smtp_injector.execute(_data(content=content)) + + mock_send_email.assert_not_called() + + @patch("email_smtp.injector.openaev_email_smtp.EmailClient.send_email") def test_process_message_reports_execution_status(mock_send_email, email_smtp_injector): mock_send_email.return_value = ExecutionResult(success=False, message="failed") diff --git a/email-smtp/tests/email_client/test_email_client.py b/email-smtp/tests/email_client/test_email_client.py index 179f51d6..3b0a8d8d 100644 --- a/email-smtp/tests/email_client/test_email_client.py +++ b/email-smtp/tests/email_client/test_email_client.py @@ -168,6 +168,61 @@ def test_send_email_with_multiple_attachments(): assert attachment_parts == ["a.txt", "b.txt"] +def test_send_email_with_one_custom_header(): + with patch("smtplib.SMTP") as mock_smtp: + instance = mock_smtp.return_value.__enter__.return_value + + result = EmailClient.send_email( + smtp_hostname="localhost", + smtp_port=1025, + smtp_use_tls=False, + smtp_username=None, + smtp_password=None, + from_email="from@example.com", + mail_from="from@example.com", + reply_to=None, + to_email="to@example.com", + cc_emails=[], + bcc_emails=[], + subject="Header Subject", + body="Header Body", + custom_headers=[("X-OpenAEV-Test", "true")], + attachments=[], + ) + + assert result.success + sent_message = instance.send_message.call_args.args[0] + assert sent_message["X-OpenAEV-Test"] == "true" + + +def test_send_email_with_multiple_custom_headers(): + with patch("smtplib.SMTP") as mock_smtp: + instance = mock_smtp.return_value.__enter__.return_value + + result = EmailClient.send_email( + smtp_hostname="localhost", + smtp_port=1025, + smtp_use_tls=False, + smtp_username=None, + smtp_password=None, + from_email="from@example.com", + mail_from="from@example.com", + reply_to=None, + to_email="to@example.com", + cc_emails=[], + bcc_emails=[], + subject="Header Subject", + body="Header Body", + custom_headers=[("X-First", "one"), ("X-Second", "two")], + attachments=[], + ) + + assert result.success + sent_message = instance.send_message.call_args.args[0] + assert sent_message["X-First"] == "one" + assert sent_message["X-Second"] == "two" + + def test_send_email_closes_connection_on_send_failure(): with patch("smtplib.SMTP") as mock_smtp: instance = mock_smtp.return_value.__enter__.return_value diff --git a/email-smtp/tests/email_payload/test_email_payload.py b/email-smtp/tests/email_payload/test_email_payload.py index 5e06dc24..be3a9cf6 100644 --- a/email-smtp/tests/email_payload/test_email_payload.py +++ b/email-smtp/tests/email_payload/test_email_payload.py @@ -1,3 +1,5 @@ +import pytest +from email_smtp.models.exceptions import CustomHeaderValidationError from email_smtp.services.utils import EmailPayloadBuilder @@ -33,6 +35,7 @@ def test_email_payload_builder(): assert payload["bcc"] == ["bcc@example.com"] assert payload["subject"] == "Hello" assert payload["body"] == "World" + assert payload["custom_headers"] == [] def test_email_payload_builder_defaults(): @@ -56,6 +59,7 @@ def test_email_payload_builder_defaults(): assert payload["reply_to"] is None assert payload["cc"] == [] assert payload["bcc"] == [] + assert payload["custom_headers"] == [] def test_email_payload_builder_empty_mail_from_falls_back_to_from(): @@ -140,3 +144,48 @@ def test_email_payload_builder_parse_bool_from_empty_string(): payload = EmailPayloadBuilder.build(content) assert not payload["smtp_use_tls"] + + +def test_email_payload_builder_parse_custom_headers(): + content = { + "smtp_hostname": "smtp.example.com", + "smtp_port": "25", + "from": "sender@example.com", + "to": "recipient@example.com", + "subject": "Hello", + "body": "World", + "custom_headers": "X-OpenAEV-Test: true\nX-Trace-ID: 123", + } + + payload = EmailPayloadBuilder.build(content) + + assert payload["custom_headers"] == [ + ("X-OpenAEV-Test", "true"), + ("X-Trace-ID", "123"), + ] + + +@pytest.mark.parametrize( + "custom_headers,expected_message", + [ + (" : true", "header name is required"), + ("unsafe header: true", "unsafe header name"), + ("X-OpenAEV-Test:", "header value is required"), + ("X-OpenAEV-Test: value\x00", "unsafe header value"), + ], +) +def test_email_payload_builder_rejects_unsafe_custom_headers( + custom_headers, expected_message +): + content = { + "smtp_hostname": "smtp.example.com", + "smtp_port": "25", + "from": "sender@example.com", + "to": "recipient@example.com", + "subject": "Hello", + "body": "World", + "custom_headers": custom_headers, + } + + with pytest.raises(CustomHeaderValidationError, match=expected_message): + EmailPayloadBuilder.build(content) From d9f38681fc0aa4426deecc8f2acd666fc949d2f4 Mon Sep 17 00:00:00 2001 From: Mariot Tsitoara Date: Wed, 22 Jul 2026 08:52:54 +0200 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- email-smtp/tests/craft_email/test_craft_email.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/email-smtp/tests/craft_email/test_craft_email.py b/email-smtp/tests/craft_email/test_craft_email.py index e131a269..5fc649d6 100644 --- a/email-smtp/tests/craft_email/test_craft_email.py +++ b/email-smtp/tests/craft_email/test_craft_email.py @@ -133,7 +133,9 @@ def test_execute_rejects_unsafe_custom_headers( @patch("email_smtp.injector.openaev_email_smtp.EmailClient.send_email") -def test_process_message_reports_execution_status(mock_send_email, email_smtp_injector): +def test_process_message_reports_execution_status( + mock_send_email, email_smtp_injector +): mock_send_email.return_value = ExecutionResult(success=False, message="failed") email_smtp_injector.process_message(_data()) From 0c210ce5bb3ae62227ff6b32359f5705c179f19e Mon Sep 17 00:00:00 2001 From: Mariot Tsitoara Date: Wed, 22 Jul 2026 09:10:15 +0200 Subject: [PATCH 3/4] fix with fmt --- email-smtp/tests/craft_email/test_craft_email.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/email-smtp/tests/craft_email/test_craft_email.py b/email-smtp/tests/craft_email/test_craft_email.py index 5fc649d6..e131a269 100644 --- a/email-smtp/tests/craft_email/test_craft_email.py +++ b/email-smtp/tests/craft_email/test_craft_email.py @@ -133,9 +133,7 @@ def test_execute_rejects_unsafe_custom_headers( @patch("email_smtp.injector.openaev_email_smtp.EmailClient.send_email") -def test_process_message_reports_execution_status( - mock_send_email, email_smtp_injector -): +def test_process_message_reports_execution_status(mock_send_email, email_smtp_injector): mock_send_email.return_value = ExecutionResult(success=False, message="failed") email_smtp_injector.process_message(_data()) From 7c985a1641c49f7aaa129c9e4a2e54ec2ca9c3fd Mon Sep 17 00:00:00 2001 From: Mariot Tsitoara Date: Tue, 28 Jul 2026 11:29:10 +0200 Subject: [PATCH 4/4] limit custom headers --- email-smtp/README.md | 34 +++++++++---------- email-smtp/email_smtp/services/utils.py | 6 ++++ .../tests/craft_email/test_craft_email.py | 5 +-- .../tests/email_payload/test_email_payload.py | 5 +-- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/email-smtp/README.md b/email-smtp/README.md index e0d158e8..b286e7da 100644 --- a/email-smtp/README.md +++ b/email-smtp/README.md @@ -128,23 +128,23 @@ contract attachment field. The injector registers a single contract labelled "Email (SMTP) - Craft email" in the `TABLE_TOP` security domain. -| Field | Content key | Mandatory | Description | -|---------------|-----------------|-----------|-----------------------------------------------------------------| -| SMTP Hostname | `smtp_hostname` | Yes | Hostname of the SMTP server used to send the email. | -| SMTP Port | `smtp_port` | Yes | Port of the SMTP server. | -| Use TLS | `smtp_use_tls` | No | Enables STARTTLS on the SMTP connection. | -| SMTP Username | `smtp_username` | No | SMTP authentication username (used together with the password). | -| SMTP Password | `smtp_password` | No | SMTP authentication password. | -| From Email | `from` | Yes | Sender address of the email. | -| Mail From | `mail_from` | No | SMTP envelope sender (MAIL FROM); defaults to `from`. | -| Reply-To | `reply_to` | No | Reply-To header address; omitted when not provided. | -| To Email | `to` | Yes | Primary recipient address. | -| Cc | `cc` | No | Comma-separated list of Cc recipients. | -| Bcc | `bcc` | No | Comma-separated list of Bcc recipients. | -| Subject | `subject` | Yes | Subject of the email. | -| Body | `body` | Yes | Plain-text body of the email. | -| Custom Headers| `custom_headers`| No | One custom header per line (`name: value`); unsafe headers are rejected. | -| Attachments | `attachments` | No | Inject documents sent as email attachments. | +| Field | Content key | Mandatory | Description | +|----------------|------------------|-----------|------------------------------------------------------------------------------------------------------------------------| +| SMTP Hostname | `smtp_hostname` | Yes | Hostname of the SMTP server used to send the email. | +| SMTP Port | `smtp_port` | Yes | Port of the SMTP server. | +| Use TLS | `smtp_use_tls` | No | Enables STARTTLS on the SMTP connection. | +| SMTP Username | `smtp_username` | No | SMTP authentication username (used together with the password). | +| SMTP Password | `smtp_password` | No | SMTP authentication password. | +| From Email | `from` | Yes | Sender address of the email. | +| Mail From | `mail_from` | No | SMTP envelope sender (MAIL FROM); defaults to `from`. | +| Reply-To | `reply_to` | No | Reply-To header address; omitted when not provided. | +| To Email | `to` | Yes | Primary recipient address. | +| Cc | `cc` | No | Comma-separated list of Cc recipients. | +| Bcc | `bcc` | No | Comma-separated list of Bcc recipients. | +| Subject | `subject` | Yes | Subject of the email. | +| Body | `body` | Yes | Plain-text body of the email. | +| Custom Headers | `custom_headers` | No | One custom header per line (`name: value`); header names must start with `X-OpenAEV-` and unsafe headers are rejected. | +| Attachments | `attachments` | No | Inject documents sent as email attachments. | ## Target selection diff --git a/email-smtp/email_smtp/services/utils.py b/email-smtp/email_smtp/services/utils.py index e73292d6..b56d5604 100644 --- a/email-smtp/email_smtp/services/utils.py +++ b/email-smtp/email_smtp/services/utils.py @@ -4,6 +4,7 @@ from email_smtp.models.exceptions import CustomHeaderValidationError HEADER_NAME_PATTERN = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$") +CUSTOM_HEADER_PREFIX = "X-OpenAEV-" class EmailPayloadBuilder: @@ -57,6 +58,11 @@ def parse_custom_headers(value: str | None) -> List[tuple[str, str]]: raise CustomHeaderValidationError( f"Invalid custom header at line {line_number}: unsafe header name" ) + if not header_name.startswith(CUSTOM_HEADER_PREFIX): + raise CustomHeaderValidationError( + f"Invalid custom header at line {line_number}: " + f"header name must start with '{CUSTOM_HEADER_PREFIX}'" + ) if not header_value: raise CustomHeaderValidationError( f"Invalid custom header at line {line_number}: header value is required" diff --git a/email-smtp/tests/craft_email/test_craft_email.py b/email-smtp/tests/craft_email/test_craft_email.py index e131a269..7b43eed0 100644 --- a/email-smtp/tests/craft_email/test_craft_email.py +++ b/email-smtp/tests/craft_email/test_craft_email.py @@ -79,7 +79,7 @@ def test_execute_parses_and_forwards_custom_headers( "to": "recipient@example.com", "subject": "Subject", "body": "Body", - "custom_headers": "X-OpenAEV-Test: true\nX-Trace-ID: abc-123", + "custom_headers": "X-OpenAEV-Test: true\nX-OpenAEV-Trace-ID: abc-123", } result = email_smtp_injector.execute(_data(content=content)) @@ -87,7 +87,7 @@ def test_execute_parses_and_forwards_custom_headers( assert result.success assert mock_send_email.call_args.kwargs["custom_headers"] == [ ("X-OpenAEV-Test", "true"), - ("X-Trace-ID", "abc-123"), + ("X-OpenAEV-Trace-ID", "abc-123"), ] @@ -109,6 +109,7 @@ def test_extract_attachments_requires_document_id(email_smtp_injector): "custom_headers,expected_message", [ ("bad header: true", "unsafe header name"), + ("X-Trace-ID: abc-123", "header name must start with 'X-OpenAEV-'"), ("X-OpenAEV-Test:", "header value is required"), ], ) diff --git a/email-smtp/tests/email_payload/test_email_payload.py b/email-smtp/tests/email_payload/test_email_payload.py index be3a9cf6..af6a9a2b 100644 --- a/email-smtp/tests/email_payload/test_email_payload.py +++ b/email-smtp/tests/email_payload/test_email_payload.py @@ -154,14 +154,14 @@ def test_email_payload_builder_parse_custom_headers(): "to": "recipient@example.com", "subject": "Hello", "body": "World", - "custom_headers": "X-OpenAEV-Test: true\nX-Trace-ID: 123", + "custom_headers": "X-OpenAEV-Test: true\nX-OpenAEV-Trace-ID: 123", } payload = EmailPayloadBuilder.build(content) assert payload["custom_headers"] == [ ("X-OpenAEV-Test", "true"), - ("X-Trace-ID", "123"), + ("X-OpenAEV-Trace-ID", "123"), ] @@ -170,6 +170,7 @@ def test_email_payload_builder_parse_custom_headers(): [ (" : true", "header name is required"), ("unsafe header: true", "unsafe header name"), + ("X-Trace-ID: 123", "header name must start with 'X-OpenAEV-'"), ("X-OpenAEV-Test:", "header value is required"), ("X-OpenAEV-Test: value\x00", "unsafe header value"), ],