Harden mail connector network boundaries
This commit is contained in:
@@ -11,7 +11,7 @@ requires-python = ">=3.12"
|
|||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.9",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"redis>=5,<6",
|
"redis>=5,<6",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ from email.message import EmailMessage
|
|||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
OutboundHttpError,
|
||||||
|
create_outbound_connection,
|
||||||
|
response_limit,
|
||||||
|
validate_outbound_host,
|
||||||
|
)
|
||||||
|
|
||||||
from govoplan_mail.backend.config import ImapConfig, TransportSecurity
|
from govoplan_mail.backend.config import ImapConfig, TransportSecurity
|
||||||
from govoplan_mail.backend.dev.mock_mailbox import (
|
from govoplan_mail.backend.dev.mock_mailbox import (
|
||||||
MOCK_IMAP_FOLDERS,
|
MOCK_IMAP_FOLDERS,
|
||||||
@@ -25,6 +32,31 @@ from govoplan_mail.backend.dev.mock_mailbox import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundPolicyIMAP4(imaplib.IMAP4):
|
||||||
|
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
|
||||||
|
return create_outbound_connection(
|
||||||
|
self.host,
|
||||||
|
self.port,
|
||||||
|
timeout=timeout,
|
||||||
|
label="IMAP connector",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundPolicyIMAP4SSL(imaplib.IMAP4_SSL):
|
||||||
|
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
|
||||||
|
sock = create_outbound_connection(
|
||||||
|
self.host,
|
||||||
|
self.port,
|
||||||
|
timeout=timeout,
|
||||||
|
label="IMAP connector",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return self.ssl_context.wrap_socket(sock, server_hostname=self.host)
|
||||||
|
except Exception:
|
||||||
|
sock.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class ImapConfigurationError(ValueError):
|
class ImapConfigurationError(ValueError):
|
||||||
"""Raised when IMAP settings are incomplete or inconsistent."""
|
"""Raised when IMAP settings are incomplete or inconsistent."""
|
||||||
|
|
||||||
@@ -163,13 +195,22 @@ def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
|
|||||||
|
|
||||||
def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
|
def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
|
||||||
host, port = _require_imap_config(config)
|
host, port = _require_imap_config(config)
|
||||||
|
try:
|
||||||
|
validate_outbound_host(host, port=port, label="IMAP connector")
|
||||||
|
except OutboundHttpError as exc:
|
||||||
|
raise ImapConfigurationError(str(exc)) from exc
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if config.security == TransportSecurity.TLS:
|
if config.security == TransportSecurity.TLS:
|
||||||
client: imaplib.IMAP4 = imaplib.IMAP4_SSL(host=host, port=port, timeout=config.timeout_seconds, ssl_context=context)
|
client: imaplib.IMAP4 = _OutboundPolicyIMAP4SSL(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
timeout=config.timeout_seconds,
|
||||||
|
ssl_context=context,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
client = imaplib.IMAP4(host=host, port=port, timeout=config.timeout_seconds)
|
client = _OutboundPolicyIMAP4(host=host, port=port, timeout=config.timeout_seconds)
|
||||||
if config.security == TransportSecurity.STARTTLS:
|
if config.security == TransportSecurity.STARTTLS:
|
||||||
typ, data = client.starttls(ssl_context=context)
|
typ, data = client.starttls(ssl_context=context)
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
@@ -704,18 +745,22 @@ def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | Non
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
|
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
|
||||||
typ, data = client.uid("fetch", str(uid), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
|
max_bytes = response_limit("file")
|
||||||
|
typ, data = client.uid("fetch", str(uid), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)")
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True)
|
raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True)
|
||||||
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
|
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
|
||||||
|
_ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message")
|
||||||
return fetched_uid or str(uid), flags, size_bytes, raw
|
return fetched_uid or str(uid), flags, size_bytes, raw
|
||||||
|
|
||||||
|
|
||||||
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
|
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
|
||||||
typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
|
max_bytes = response_limit("file")
|
||||||
|
typ, data = client.fetch(str(sequence), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)")
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
|
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
|
||||||
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
|
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
|
||||||
|
_ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message")
|
||||||
if not fetched_uid:
|
if not fetched_uid:
|
||||||
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
|
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
|
||||||
return fetched_uid, flags, size_bytes, raw
|
return fetched_uid, flags, size_bytes, raw
|
||||||
@@ -734,12 +779,19 @@ def _sequence_set(sequences: list[str]) -> str:
|
|||||||
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
|
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
|
||||||
if not sequences:
|
if not sequences:
|
||||||
return []
|
return []
|
||||||
typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
|
max_bytes = response_limit("structured")
|
||||||
|
per_message_bytes = max(1, max_bytes // len(sequences)) + 1
|
||||||
|
typ, data = client.fetch(
|
||||||
|
_sequence_set(sequences),
|
||||||
|
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)",
|
||||||
|
)
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
|
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
|
||||||
|
|
||||||
by_sequence: dict[str, ImapMailboxMessageSummary] = {}
|
by_sequence: dict[str, ImapMailboxMessageSummary] = {}
|
||||||
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
|
parsed_parts = _parse_fetch_parts_with_sequence(data)
|
||||||
|
_ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response")
|
||||||
|
for sequence, fetched_uid, flags, size_bytes, headers in parsed_parts:
|
||||||
if not sequence or not fetched_uid:
|
if not sequence or not fetched_uid:
|
||||||
continue
|
continue
|
||||||
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
|
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
|
||||||
@@ -791,12 +843,20 @@ def _paged_descending_uids(
|
|||||||
def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
|
def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
|
||||||
if not uids:
|
if not uids:
|
||||||
return []
|
return []
|
||||||
typ, data = client.uid("fetch", _sequence_set(uids), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
|
max_bytes = response_limit("structured")
|
||||||
|
per_message_bytes = max(1, max_bytes // len(uids)) + 1
|
||||||
|
typ, data = client.uid(
|
||||||
|
"fetch",
|
||||||
|
_sequence_set(uids),
|
||||||
|
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)",
|
||||||
|
)
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
|
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
|
||||||
|
|
||||||
by_uid: dict[str, ImapMailboxMessageSummary] = {}
|
by_uid: dict[str, ImapMailboxMessageSummary] = {}
|
||||||
for _sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
|
parsed_parts = _parse_fetch_parts_with_sequence(data)
|
||||||
|
_ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response")
|
||||||
|
for _sequence, fetched_uid, flags, size_bytes, headers in parsed_parts:
|
||||||
if not fetched_uid:
|
if not fetched_uid:
|
||||||
continue
|
continue
|
||||||
by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
|
by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
|
||||||
@@ -812,6 +872,27 @@ def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], fold
|
|||||||
return summaries
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_imap_payload_within_limit(
|
||||||
|
payload: bytes,
|
||||||
|
*,
|
||||||
|
declared_size: int | None,
|
||||||
|
max_bytes: int,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
if (declared_size is not None and declared_size > max_bytes) or len(payload) > max_bytes:
|
||||||
|
raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_imap_batch_within_limit(
|
||||||
|
parts: list[tuple[str | None, str | None, list[str], int | None, bytes]],
|
||||||
|
*,
|
||||||
|
max_bytes: int,
|
||||||
|
label: str,
|
||||||
|
) -> None:
|
||||||
|
if sum(len(part[4]) for part in parts) > max_bytes:
|
||||||
|
raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False)
|
||||||
|
|
||||||
|
|
||||||
def _mock_record_folder(record: dict[str, Any]) -> str:
|
def _mock_record_folder(record: dict[str, Any]) -> str:
|
||||||
folder = str(record.get("folder") or "").strip()
|
folder = str(record.get("folder") or "").strip()
|
||||||
if folder:
|
if folder:
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ from dataclasses import dataclass
|
|||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
|
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
OutboundHttpError,
|
||||||
|
create_outbound_connection,
|
||||||
|
validate_outbound_host,
|
||||||
|
)
|
||||||
|
|
||||||
from govoplan_mail.backend.config import SmtpConfig, TransportSecurity
|
from govoplan_mail.backend.config import SmtpConfig, TransportSecurity
|
||||||
from govoplan_mail.backend.dev.mock_mailbox import (
|
from govoplan_mail.backend.dev.mock_mailbox import (
|
||||||
consume_fail_next_smtp,
|
consume_fail_next_smtp,
|
||||||
@@ -19,6 +25,33 @@ from govoplan_mail.backend.dev.mock_mailbox import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundPolicySMTP(smtplib.SMTP):
|
||||||
|
def _get_socket(self, host: str, port: int, timeout: float | None): # type: ignore[no-untyped-def]
|
||||||
|
return create_outbound_connection(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
timeout=timeout,
|
||||||
|
source_address=self.source_address,
|
||||||
|
label="SMTP connector",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundPolicySMTPSSL(smtplib.SMTP_SSL):
|
||||||
|
def _get_socket(self, host: str, port: int, timeout: float | None): # type: ignore[no-untyped-def]
|
||||||
|
sock = create_outbound_connection(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
timeout=timeout,
|
||||||
|
source_address=self.source_address,
|
||||||
|
label="SMTP connector",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return self.context.wrap_socket(sock, server_hostname=self._host)
|
||||||
|
except Exception:
|
||||||
|
sock.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class SmtpConfigurationError(ValueError):
|
class SmtpConfigurationError(ValueError):
|
||||||
"""Raised when SMTP settings are incomplete or inconsistent."""
|
"""Raised when SMTP settings are incomplete or inconsistent."""
|
||||||
|
|
||||||
@@ -75,14 +108,23 @@ def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
|
|||||||
|
|
||||||
def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
|
def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
|
||||||
host, port = _require_smtp_config(config)
|
host, port = _require_smtp_config(config)
|
||||||
|
try:
|
||||||
|
validate_outbound_host(host, port=port, label="SMTP connector")
|
||||||
|
except OutboundHttpError as exc:
|
||||||
|
raise SmtpConfigurationError(str(exc)) from exc
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if config.security == TransportSecurity.TLS:
|
if config.security == TransportSecurity.TLS:
|
||||||
smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context)
|
smtp: smtplib.SMTP = _OutboundPolicySMTPSSL(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
timeout=config.timeout_seconds,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
smtp.ehlo()
|
smtp.ehlo()
|
||||||
else:
|
else:
|
||||||
smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds)
|
smtp = _OutboundPolicySMTP(host=host, port=port, timeout=config.timeout_seconds)
|
||||||
smtp.ehlo()
|
smtp.ehlo()
|
||||||
if config.security == TransportSecurity.STARTTLS:
|
if config.security == TransportSecurity.STARTTLS:
|
||||||
smtp.starttls(context=context)
|
smtp.starttls(context=context)
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.security.outbound_http import OutboundHttpBlocked
|
||||||
from govoplan_mail.backend.config import ImapConfig
|
from govoplan_mail.backend.config import ImapConfig
|
||||||
from govoplan_mail.backend.sending.imap import (
|
from govoplan_mail.backend.sending.imap import (
|
||||||
|
ImapAppendError,
|
||||||
|
ImapConfigurationError,
|
||||||
_detect_sent_folder,
|
_detect_sent_folder,
|
||||||
_extract_mailbox_name,
|
_extract_mailbox_name,
|
||||||
|
_fetch_message_by_uid,
|
||||||
_normalize_mailbox_page,
|
_normalize_mailbox_page,
|
||||||
|
_open_imap,
|
||||||
_paged_descending_sequences,
|
_paged_descending_sequences,
|
||||||
_parse_fetch_sequence,
|
_parse_fetch_sequence,
|
||||||
_select_readonly,
|
_select_readonly,
|
||||||
@@ -17,6 +22,31 @@ from govoplan_mail.backend.sending.imap import (
|
|||||||
|
|
||||||
|
|
||||||
class ImapFolderParserTests(unittest.TestCase):
|
class ImapFolderParserTests(unittest.TestCase):
|
||||||
|
def test_real_imap_connections_honor_deployment_egress_policy(self):
|
||||||
|
config = ImapConfig(host="imap.internal", port=993, security="tls")
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.sending.imap.validate_outbound_host",
|
||||||
|
side_effect=OutboundHttpBlocked("private network blocked"),
|
||||||
|
), self.assertRaisesRegex(ImapConfigurationError, "private network blocked"):
|
||||||
|
_open_imap(config)
|
||||||
|
|
||||||
|
def test_imap_revalidates_and_pins_at_connection_time(self):
|
||||||
|
config = ImapConfig(host="imap.example.test", port=993, security="tls")
|
||||||
|
public = [(2, 1, 6, "", ("93.184.216.34", 993))]
|
||||||
|
private = [(2, 1, 6, "", ("127.0.0.1", 993))]
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
|
||||||
|
), patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
side_effect=(public, private),
|
||||||
|
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
|
||||||
|
OutboundHttpBlocked,
|
||||||
|
"non-public network",
|
||||||
|
):
|
||||||
|
_open_imap(config)
|
||||||
|
socket_factory.assert_not_called()
|
||||||
|
|
||||||
def test_extracts_quoted_mailbox_after_quoted_delimiter(self):
|
def test_extracts_quoted_mailbox_after_quoted_delimiter(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
_extract_mailbox_name(b'(\\HasNoChildren \\Sent) "/" "Sent Items"'),
|
_extract_mailbox_name(b'(\\HasNoChildren \\Sent) "/" "Sent Items"'),
|
||||||
@@ -49,6 +79,23 @@ class ImapFolderParserTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class ImapMessagePaginationTests(unittest.TestCase):
|
class ImapMessagePaginationTests(unittest.TestCase):
|
||||||
|
def test_full_message_fetch_uses_partial_range_and_deployment_limit(self):
|
||||||
|
class Client:
|
||||||
|
command = ""
|
||||||
|
|
||||||
|
def uid(self, command, uid, fetch_spec):
|
||||||
|
del command, uid
|
||||||
|
self.command = fetch_spec
|
||||||
|
return "OK", [(b"1 (UID 1 RFC822.SIZE 11)", b"12345678901")]
|
||||||
|
|
||||||
|
client = Client()
|
||||||
|
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "10"}), self.assertRaisesRegex(
|
||||||
|
ImapAppendError,
|
||||||
|
"deployment limit",
|
||||||
|
):
|
||||||
|
_fetch_message_by_uid(client, "1")
|
||||||
|
self.assertIn("BODY.PEEK[]<0.11>", client.command)
|
||||||
|
|
||||||
def test_paginates_sequence_numbers_newest_first(self):
|
def test_paginates_sequence_numbers_newest_first(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
_paged_descending_sequences(120, offset=0, limit=5),
|
_paged_descending_sequences(120, offset=0, limit=5),
|
||||||
|
|||||||
@@ -1,16 +1,44 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.security.outbound_http import OutboundHttpBlocked
|
||||||
from govoplan_mail.backend.config import SmtpConfig
|
from govoplan_mail.backend.config import SmtpConfig
|
||||||
from govoplan_mail.backend.sending.smtp import (
|
from govoplan_mail.backend.sending.smtp import (
|
||||||
SmtpConfigurationError,
|
SmtpConfigurationError,
|
||||||
|
_open_smtp,
|
||||||
_prepare_smtp_send,
|
_prepare_smtp_send,
|
||||||
_smtp_send_result,
|
_smtp_send_result,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SmtpSendHelperTests(unittest.TestCase):
|
class SmtpSendHelperTests(unittest.TestCase):
|
||||||
|
def test_real_smtp_connections_honor_deployment_egress_policy(self):
|
||||||
|
config = SmtpConfig(host="smtp.internal", port=587, security="starttls")
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.sending.smtp.validate_outbound_host",
|
||||||
|
side_effect=OutboundHttpBlocked("private network blocked"),
|
||||||
|
), self.assertRaisesRegex(SmtpConfigurationError, "private network blocked"):
|
||||||
|
_open_smtp(config)
|
||||||
|
|
||||||
|
def test_smtp_revalidates_and_pins_at_connection_time(self):
|
||||||
|
config = SmtpConfig(host="smtp.example.test", port=587, security="starttls")
|
||||||
|
public = [(2, 1, 6, "", ("93.184.216.34", 587))]
|
||||||
|
private = [(2, 1, 6, "", ("127.0.0.1", 587))]
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
|
||||||
|
), patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
side_effect=(public, private),
|
||||||
|
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
|
||||||
|
OutboundHttpBlocked,
|
||||||
|
"non-public network",
|
||||||
|
):
|
||||||
|
_open_smtp(config)
|
||||||
|
socket_factory.assert_not_called()
|
||||||
|
|
||||||
def test_prepare_smtp_send_validates_envelope(self):
|
def test_prepare_smtp_send_validates_envelope(self):
|
||||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||||
with self.assertRaisesRegex(SmtpConfigurationError, "envelope sender"):
|
with self.assertRaisesRegex(SmtpConfigurationError, "envelope sender"):
|
||||||
@@ -45,4 +73,3 @@ class SmtpSendHelperTests(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user