Skip to content

🐛(smtp) fix opportunistic TLS against MXes with mismatched certs#687

Open
sylvinus wants to merge 3 commits into
mainfrom
fix_smtp_tls_may
Open

🐛(smtp) fix opportunistic TLS against MXes with mismatched certs#687
sylvinus wants to merge 3 commits into
mainfrom
fix_smtp_tls_may

Conversation

@sylvinus
Copy link
Copy Markdown
Member

@sylvinus sylvinus commented May 30, 2026

The "may" level was verifying the peer cert and falling back to cleartext on mismatch, which then bounced on STARTTLS-required servers (e.g. Mandrill's SES-backed inbound returns 530 to MAIL FROM in cleartext). Realigned on Postfix's documented behavior:

  • "may": opportunistic TLS, no cert verification.
  • "secure": mandatory TLS + CA chain + hostname check; defers if STARTTLS isn't advertised or handshake fails.
  • "encrypt" is dropped (replaced by "secure").

Also wires MTA_OUT_SMTP_TLS_SECURITY_LEVEL through both the direct and relay paths — it had been declared but never read — and collapses the four proxy_* kwargs + sender_hostname of send_smtp_mail into a single SmtpProxy dataclass.

Summary by CodeRabbit

  • Documentation

    • Clarified SMTP TLS security level docs and listed allowed values (none, may, secure); applies to direct and relay delivery.
  • New Features

    • Unified proxy configuration for direct SMTP sending (single proxy object used).
  • Bug Fixes

    • TLS level consistently honored across outbound paths.
    • STARTTLS behavior: "may" falls back to cleartext on TLS failure; "secure" defers or fails when TLS is unavailable.
  • Tests

    • Added/updated tests for STARTTLS behaviors and proxy handling.

Review Change Stack

The "may" level was verifying the peer cert and falling back to
cleartext on mismatch, which then bounced on STARTTLS-required
servers (e.g. Mandrill's SES-backed inbound returns 530 to MAIL
FROM in cleartext). Realigned on Postfix's documented behavior:

- "may":    opportunistic TLS, no cert verification.
- "secure": mandatory TLS + CA chain + hostname check; defers if
            STARTTLS isn't advertised or handshake fails.
- "encrypt" is dropped (replaced by "secure").

Also wires MTA_OUT_SMTP_TLS_SECURITY_LEVEL through both the direct
and relay paths — it had been declared but never read — and
collapses the four proxy_* kwargs + sender_hostname of
send_smtp_mail into a single SmtpProxy dataclass.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 569cc3ab-1456-4a6f-913a-221c991853d8

📥 Commits

Reviewing files that changed from the base of the PR and between daeb3a9 and 93f2de6.

📒 Files selected for processing (1)
  • src/backend/core/tests/mda/test_smtp.py

📝 Walkthrough

Walkthrough

Typed SmtpProxy and settings docs added; send_smtp_mail now accepts a proxy object and smtp_tls_security_level. STARTTLS/TLS behavior moved into helpers with policy-aware fallback/deferral. Outbound direct and relay paths, tests, and docs updated accordingly.

Changes

SMTP Proxy and TLS Security Refactoring

Layer / File(s) Summary
SmtpProxy contract and TLS policy documentation
src/backend/core/mda/smtp.py, src/backend/messages/settings.py, docs/env.md
Adds SmtpProxy dataclass, updates send_smtp_mail docstrings, expands settings validation/docs to accept "secure" (with "may" and "none"), and updates env documentation.
STARTTLS and TLS context helpers
src/backend/core/mda/smtp.py
Adds _build_tls_context() and _starttls_upgrade(); send_smtp_mail now accepts proxy: Optional[SmtpProxy], derives proxy fields from it, and uses helpers for STARTTLS with policy-aware "fallback" and error returns.
Direct SMTP outbound sender
src/backend/core/mda/outbound_direct.py
select_smtp_proxy() returns Optional[SmtpProxy] (or None); send_message_via_mx passes proxy=select_smtp_proxy() and smtp_tls_security_level to send_smtp_mail.
Relay SMTP outbound sender
src/backend/core/mda/outbound.py
Relay-mode send_smtp_mail calls now include smtp_tls_security_level=settings.MTA_OUT_SMTP_TLS_SECURITY_LEVEL.
Outbound delivery test proxy assertions
src/backend/core/tests/mda/test_outbound.py
Tests construct expected_proxy = SmtpProxy(...) and assert proxy=expected_proxy for direct sends; relay assertion includes smtp_tls_security_level="may"; no-proxy path expects proxy=None.
SMTP client test infrastructure and TLS tests
src/backend/core/tests/mda/test_smtp.py
Mock SMTP server can advertise STARTTLS or simulate handshake failure; tests updated to pass proxy=SmtpProxy(...) and new tests added for "secure" deferral and "may" fallback behaviors.

Sequence Diagram

sequenceDiagram
  participant Client as Outbound Caller
  participant SendMail as send_smtp_mail
  participant Upgrade as _starttls_upgrade
  participant Server as SMTP Server
  Client->>SendMail: send(message, proxy, level="may")
  SendMail->>Upgrade: _starttls_upgrade(level, sender_hostname)
  Upgrade->>Server: STARTTLS
  Server-->>Upgrade: handshake fails (connection close)
  Upgrade-->>SendMail: "fallback"
  SendMail->>SendMail: retry with level="none"
  SendMail->>Server: deliver plaintext
  Server-->>SendMail: accepted
  SendMail-->>Client: success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • suitenumerique/messages#336: Implements the core smtp_tls_security_level policy and "secure"/"may"/"none" semantics that this PR refactors into typed SmtpProxy and helper functions.
  • suitenumerique/messages#626: Adjusts per-recipient status payloads and error formatting in outbound.py for the same proxy host/security level parameters that this PR restructures via SmtpProxy dataclass.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title specifically addresses the core issue (opportunistic TLS behavior against MXes with certificate mismatches) and aligns with the PR's main objectives of fixing SMTP TLS handling and redefining the 'may' security level.
Docstring Coverage ✅ Passed Docstring coverage is 84.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/backend/core/tests/mda/test_outbound.py (1)

351-392: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the remaining send_smtp_mail assertions to the new signature.

This hunk updates the direct-path expectations, but test_outbound_send_relay and test_outbound_send_direct_no_mx still use assert_called_once_with(...) without the new kwargs. Both will fail now that relay adds smtp_tls_security_level, and the no-proxy direct path also passes proxy=None.

Suggested test updates
@@
     `@override_settings`(
         MTA_OUT_MODE="relay",
         MTA_OUT_RELAY_HOST="smtp.test:1025",
         # Ensure other auth settings are None for this test
         MTA_OUT_RELAY_USERNAME="smtp_user",
         MTA_OUT_RELAY_PASSWORD="smtp_pass",
+        MTA_OUT_SMTP_TLS_SECURITY_LEVEL="may",
         OPENSEARCH_INDEX_THREADS=False,
     )
@@
         mock_smtp_send.assert_called_once_with(
             smtp_host="smtp.test",
             smtp_port=1025,
             envelope_from=draft_message.sender.email,
             recipient_emails={
@@
             message_content=draft_message.blob.get_content(),
             smtp_username="smtp_user",
             smtp_password="smtp_pass",
+            smtp_tls_security_level="may",
         )
@@
     `@override_settings`(
         MTA_OUT_MODE="direct",
+        MTA_OUT_SMTP_TLS_SECURITY_LEVEL="may",
         OPENSEARCH_INDEX_THREADS=False,
     )
@@
         mock_smtp_send.assert_called_once_with(
             smtp_host="example2.com",
             smtp_ip="1.2.0.8",
             smtp_port=25,
             envelope_from=draft_message.sender.email,
             recipient_emails={"bcc@example2.com"},
             message_content=draft_message.blob.get_content(),
+            smtp_tls_security_level="may",
+            proxy=None,
         )
As per coding guidelines, "Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/tests/mda/test_outbound.py` around lines 351 - 392, The
remaining tests use the old send_smtp_mail signature and must be updated to
match the new kwargs: update assertions in test_outbound_send_relay and
test_outbound_send_direct_no_mx to include smtp_tls_security_level="may" (or the
appropriate level for that scenario) and include proxy=None for direct/no-proxy
calls; locate the send_smtp_mail mock assertions (e.g.,
assert_called_once_with(...) in those tests) and add the smtp_tls_security_level
and proxy keyword arguments so the expected call signature matches the
implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/tests/mda/test_smtp.py`:
- Around line 429-452: Add a new unit test that mirrors
test_may_falls_back_when_starttls_handshake_fails but uses
smtp_tls_security_level="secure" to ensure a STARTTLS handshake failure does NOT
fall back to cleartext; instantiate MixedResponseSMTPHandler, set
advertise_starttls = True and starttls_break_handshake = True, start the
handler, call send_smtp_mail with smtp_host, smtp_port=smtp_handler.port,
envelope_from, recipient_emails, message_content, timeout and
smtp_tls_security_level="secure", then assert the delivery for the recipient is
False (or that send_smtp_mail signals failure) and finally stop the handler.
Ensure the test name clearly indicates it verifies secure-mode handshake failure
(e.g., test_secure_fails_on_starttls_handshake_failure) and keeps assertions
minimal.

In `@src/backend/messages/settings.py`:
- Around line 1230-1233: The validation currently rejects the legacy value
"encrypt" for MTA_OUT_SMTP_TLS_SECURITY_LEVEL; update the check to accept
"encrypt" by normalizing it to "secure" and emit a deprecation warning instead
of raising an error. Specifically, in settings.py where
MTA_OUT_SMTP_TLS_SECURITY_LEVEL is validated, detect if its value == "encrypt",
replace/set it to "secure" and call warnings.warn (or processLogger.warning)
with a clear deprecation message advising users to switch to "secure", then
continue the existing membership check against {"none","may","secure"} so
existing deployments do not fail on startup.

---

Outside diff comments:
In `@src/backend/core/tests/mda/test_outbound.py`:
- Around line 351-392: The remaining tests use the old send_smtp_mail signature
and must be updated to match the new kwargs: update assertions in
test_outbound_send_relay and test_outbound_send_direct_no_mx to include
smtp_tls_security_level="may" (or the appropriate level for that scenario) and
include proxy=None for direct/no-proxy calls; locate the send_smtp_mail mock
assertions (e.g., assert_called_once_with(...) in those tests) and add the
smtp_tls_security_level and proxy keyword arguments so the expected call
signature matches the implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: db8771cb-0373-459e-a3fb-d2a30b98c6bf

📥 Commits

Reviewing files that changed from the base of the PR and between d91e97d and 64462c6.

📒 Files selected for processing (7)
  • docs/env.md
  • src/backend/core/mda/outbound.py
  • src/backend/core/mda/outbound_direct.py
  • src/backend/core/mda/smtp.py
  • src/backend/core/tests/mda/test_outbound.py
  • src/backend/core/tests/mda/test_smtp.py
  • src/backend/messages/settings.py

Comment thread src/backend/core/tests/mda/test_smtp.py
Comment thread src/backend/messages/settings.py
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/tests/mda/test_smtp.py`:
- Around line 454-475: The test test_secure_fails_on_starttls_handshake_failure
currently only asserts delivered is False, which allows permanent failures;
modify the assertion against the send_smtp_mail result for "user1@example.com"
to assert that the delivery was deferred (e.g.
result["user1@example.com"]["deferred"] is True or
result["user1@example.com"]["status"] == "deferred"); if your result shape uses
a different convention, assert the transient nature instead (for example ensure
a "permanent" flag is False or an error_code indicates a temporary/defer) so
MixedResponseSMTPHandler starttls_break_handshake +
smtp_tls_security_level="secure" proves a defer, not a permanent failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 91750ed0-3688-4078-8715-083de8da7d90

📥 Commits

Reviewing files that changed from the base of the PR and between 64462c6 and daeb3a9.

📒 Files selected for processing (2)
  • src/backend/core/tests/mda/test_outbound.py
  • src/backend/core/tests/mda/test_smtp.py

Comment thread src/backend/core/tests/mda/test_smtp.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant