diff --git a/email-smtp/README.md b/email-smtp/README.md index c0fa9580..b286e7da 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. @@ -127,22 +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. | -| 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 @@ -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..b56d5604 100644 --- a/email-smtp/email_smtp/services/utils.py +++ b/email-smtp/email_smtp/services/utils.py @@ -1,5 +1,11 @@ +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!#$%&'*+\-.^_`|~]+$") +CUSTOM_HEADER_PREFIX = "X-OpenAEV-" + class EmailPayloadBuilder: @staticmethod @@ -25,6 +31,51 @@ 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_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" + ) + 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 +97,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..7b43eed0 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-OpenAEV-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-OpenAEV-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,34 @@ def test_extract_attachments_requires_document_id(email_smtp_injector): ) +@pytest.mark.parametrize( + "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"), + ], +) +@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..af6a9a2b 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,49 @@ 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-OpenAEV-Trace-ID: 123", + } + + payload = EmailPayloadBuilder.build(content) + + assert payload["custom_headers"] == [ + ("X-OpenAEV-Test", "true"), + ("X-OpenAEV-Trace-ID", "123"), + ] + + +@pytest.mark.parametrize( + "custom_headers,expected_message", + [ + (" : 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"), + ], +) +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)