Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions email-smtp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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.

Expand Down
6 changes: 6 additions & 0 deletions email-smtp/email_smtp/contracts/craft_email/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
Expand Down
1 change: 1 addition & 0 deletions email-smtp/email_smtp/injector/openaev_email_smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions email-smtp/email_smtp/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from email_smtp.models.configs import ConfigLoader, InjectorConfigOverride
from email_smtp.models.exceptions import (
AttachmentDownloadError,
CustomHeaderValidationError,
EmailInjectorError,
InvalidContractError,
MissingRequiredFieldError,
Expand All @@ -13,4 +14,5 @@
"InvalidContractError",
"MissingRequiredFieldError",
"AttachmentDownloadError",
"CustomHeaderValidationError",
]
6 changes: 6 additions & 0 deletions email-smtp/email_smtp/models/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions email-smtp/email_smtp/services/email_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions email-smtp/email_smtp/services/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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")
),
}
58 changes: 57 additions & 1 deletion email-smtp/tests/craft_email/test_craft_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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")
Expand Down
55 changes: 55 additions & 0 deletions email-smtp/tests/email_client/test_email_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading