Skip to content

Add HTTP CONNECT proxy support to SMTP service#125

Open
mithunbharadwaj wants to merge 7 commits intomainfrom
Feature/SMTP-Supports-Proxy
Open

Add HTTP CONNECT proxy support to SMTP service#125
mithunbharadwaj wants to merge 7 commits intomainfrom
Feature/SMTP-Supports-Proxy

Conversation

@mithunbharadwaj
Copy link
Copy Markdown
Contributor

@mithunbharadwaj mithunbharadwaj commented Mar 2, 2026

Summary by CodeRabbit

  • New Features

    • Added HTTP CONNECT proxy support for SMTP with configurable host, port, optional auth, and timeout.
    • Unified recipient normalization and deduplication; send now uses explicit keyword-only parameters and email_cc/email_bcc default to None.
  • Bug Fixes

    • Improved retry and error handling for proxy and SMTP failures; ensures proxy connections are cleaned up on error.
  • Documentation

    • README updated to document proxy configuration and enabling conditions.

@mithunbharadwaj mithunbharadwaj self-assigned this Mar 2, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 2, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds HTTP CONNECT proxy support and related config; introduces ProxyConnectError; refactors recipient normalization and header construction; updates send() signature defaults; implements proxy-aware SMTP send flow with helper methods, retries, and cleanup.

Changes

Cohort / File(s) Summary
SMTP Proxy Support & Recipient Refactor
asabiris/output/smtp/service.py
Adds ProxyConnectError; new proxy config keys (proxy_host, proxy_port, proxy_user, proxy_password, proxy_connect_timeout) with validation; email_cc/email_bcc default to None; normalizes/deduplicates recipients and enforces non-empty recipient set; rewrites To/Cc/Bcc header and envelope handling; adds _effective_smtp_port, _proxy_connect_headers, _read_http_headers, _connect_via_http_proxy, _send_via_proxy_smtp_client; implements HTTP CONNECT proxy path, proxy-aware aiosmtplib usage, TLS handling, retry logic for proxy/SMTP errors, and socket cleanup.
Docs: Proxy configuration note
README.md
Documents optional HTTP CONNECT proxy configuration keys and states proxy support is enabled only when both proxy_host and proxy_port are set.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Service as EmailOutputService
    participant Proxy as HTTP_PROXY
    participant SMTP as SMTP_SERVER

    Client->>Service: send(email_to, body, ...)
    Service->>Service: validate config, normalize recipients

    alt proxy configured
        Service->>Proxy: TCP connect to proxy_host:proxy_port
        Service->>Proxy: HTTP CONNECT target_host:target_port (+ Proxy-Authorization)
        Proxy-->>Service: 200 Connection Established
        Service->>Service: wrap tunneled socket (optional TLS)
        Service->>SMTP: SMTP handshake/auth over tunneled socket
    else direct
        Service->>SMTP: connect to smtp_host:smtp_port (TLS/STARTTLS as needed)
    end

    Service->>SMTP: MAIL FROM / RCPT TO (normalized recipients)
    Service->>SMTP: DATA (message)
    SMTP-->>Service: 250 OK
    Service->>SMTP: QUIT
    Service-->>Client: send result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I tunneled through a proxy deep,
SMTP dreams I vowed to keep,
Recipients neat, headers bright,
Sockets closed each peaceful night,
A rabbit hops — the mail takes flight. 🐇📬

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately and concisely describes the main change: adding HTTP CONNECT proxy support to the SMTP service.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch Feature/SMTP-Supports-Proxy
📝 Coding Plan
  • Generate coding plan for human review comments

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

🧹 Nitpick comments (3)
asabiris/output/smtp/service.py (3)

257-264: Add exception chaining for better debugging context.

When re-raising as ASABIrisError, the original ProxyConnectError traceback is lost. Use raise ... from e to preserve the exception chain.

♻️ Proposed fix
-				raise ASABIrisError(
+				raise ASABIrisError(
 					ErrorCode.SMTP_CONNECTION_ERROR,
 					tech_message="SMTP proxy connection failed: {}.".format(str(e)),
 					error_i18n_key="Could not connect to SMTP for host '{{host}}'.",
 					error_dict={
 						"host": self.Host,
 					}
-				)
+				) from e
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 257 - 264, The ASABIrisError
raised in the SMTP proxy connection failure block (using ASABIrisError and
ErrorCode.SMTP_CONNECTION_ERROR) currently discards the original
ProxyConnectError traceback; update the raise to chain the original exception by
re-raising the ASABIrisError using "raise ... from e" so the original exception
(variable e) is preserved with its traceback while keeping the same
tech_message, error_i18n_key, and error_dict (including self.Host).

396-396: Consider adding IPv6 support for proxy connections.

Using socket.AF_INET limits proxy connections to IPv4 only. If the proxy host resolves to an IPv6 address, this will fail.

♻️ Proposed fix using getaddrinfo for dual-stack support
-		sock_obj = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-		sock_obj.setblocking(False)
-		loop = asyncio.get_running_loop()
-
 		try:
+			# Resolve address to support both IPv4 and IPv6
+			addr_info = socket.getaddrinfo(self.ProxyHost, proxy_port, socket.AF_UNSPEC, socket.SOCK_STREAM)
+			if not addr_info:
+				raise ProxyConnectError("Could not resolve proxy host '{}'".format(self.ProxyHost))
+			family, socktype, proto, _, sockaddr = addr_info[0]
+			sock_obj = socket.socket(family, socktype, proto)
+			sock_obj.setblocking(False)
+			loop = asyncio.get_running_loop()
 			await asyncio.wait_for(
-				loop.sock_connect(sock_obj, (self.ProxyHost, proxy_port)),
+				loop.sock_connect(sock_obj, sockaddr),
 				timeout=self.ProxyConnectTimeout
 			)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` at line 396, The socket is created with
socket.AF_INET which forces IPv4; update the proxy connection code (where
sock_obj is created) to use socket.getaddrinfo(host, port,
family=socket.AF_UNSPEC, type=socket.SOCK_STREAM) and iterate over returned
address tuples, creating each socket with the returned ai_family (instead of
socket.AF_INET), attempting connect and breaking on success, and ensuring failed
sockets are closed and exceptions propagated; this enables IPv6/IPv4 dual-stack
support while preserving existing connect logic around sock_obj.

414-426: Add exception chaining to preserve debugging context.

Multiple raise ProxyConnectError(...) statements within except blocks lose the original exception traceback. Use raise ... from e (or from None if intentionally suppressing) to preserve context for debugging.

♻️ Proposed fixes
 		except asyncio.TimeoutError:
 			sock_obj.close()
-			raise ProxyConnectError("Timeout while connecting to proxy {}:{}".format(self.ProxyHost, proxy_port))
+			raise ProxyConnectError("Timeout while connecting to proxy {}:{}".format(self.ProxyHost, proxy_port)) from None
 		except Exception as e:
 			sock_obj.close()
-			raise ProxyConnectError(str(e))
+			raise ProxyConnectError(str(e)) from e

 		try:
 			status_line = header_bytes.split(b"\r\n", 1)[0].decode("iso-8859-1")
 			status_code = int(status_line.split(" ", 2)[1])
 		except Exception as e:
 			sock_obj.close()
-			raise ProxyConnectError("Malformed proxy CONNECT response: {}".format(str(e)))
+			raise ProxyConnectError("Malformed proxy CONNECT response: {}".format(str(e))) from e
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 414 - 426, The except blocks
around the proxy connection and response parsing should preserve original
tracebacks by chaining exceptions: change "except asyncio.TimeoutError:" to
"except asyncio.TimeoutError as e:" and raise ProxyConnectError(... ) from e;
update the first "except Exception as e:" raise to "raise
ProxyConnectError(str(e)) from e"; and update the response-parsing "except
Exception as e:" raise to "raise ProxyConnectError('Malformed proxy CONNECT
response: {}'.format(str(e))) from e" while keeping the sock_obj.close() calls
and maintaining existing messages; reference the ProxyConnectError class,
sock_obj close calls, header_bytes/status_line parsing and the status_code
extraction in your edits.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@asabiris/output/smtp/service.py`:
- Around line 177-185: The Cc header is being set even when email_cc is an empty
list (because earlier code normalizes None to []), causing msg['Cc'] = '' —
update the logic in the block handling email_cc (the variables email_cc,
formatted_email_cc, cc_recipients and the call to self.format_sender_info) so
that you only set msg['Cc'] when there are actual addresses: build
formatted_email_cc as you do, append addresses to cc_recipients, and then check
that formatted_email_cc (or cc_recipients) is non-empty before assigning
msg['Cc'] (i.e., use a truthy check like if formatted_email_cc: to avoid
creating an empty header).
- Around line 235-244: When a proxy socket is used (self.ProxyHost true and
socket created by _connect_via_http_proxy) the code currently sets
send_kwargs["hostname"] = None which breaks TLS SNI; change the proxy branch so
send_kwargs["sock"] = proxy_socket, send_kwargs["hostname"] = self.Host (not
None) and keep send_kwargs["port"] = None before calling aiosmtplib.send(msg,
**send_kwargs) so TLS certificate negotiation uses the target host.

---

Nitpick comments:
In `@asabiris/output/smtp/service.py`:
- Around line 257-264: The ASABIrisError raised in the SMTP proxy connection
failure block (using ASABIrisError and ErrorCode.SMTP_CONNECTION_ERROR)
currently discards the original ProxyConnectError traceback; update the raise to
chain the original exception by re-raising the ASABIrisError using "raise ...
from e" so the original exception (variable e) is preserved with its traceback
while keeping the same tech_message, error_i18n_key, and error_dict (including
self.Host).
- Line 396: The socket is created with socket.AF_INET which forces IPv4; update
the proxy connection code (where sock_obj is created) to use
socket.getaddrinfo(host, port, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM)
and iterate over returned address tuples, creating each socket with the returned
ai_family (instead of socket.AF_INET), attempting connect and breaking on
success, and ensuring failed sockets are closed and exceptions propagated; this
enables IPv6/IPv4 dual-stack support while preserving existing connect logic
around sock_obj.
- Around line 414-426: The except blocks around the proxy connection and
response parsing should preserve original tracebacks by chaining exceptions:
change "except asyncio.TimeoutError:" to "except asyncio.TimeoutError as e:" and
raise ProxyConnectError(... ) from e; update the first "except Exception as e:"
raise to "raise ProxyConnectError(str(e)) from e"; and update the
response-parsing "except Exception as e:" raise to "raise
ProxyConnectError('Malformed proxy CONNECT response: {}'.format(str(e))) from e"
while keeping the sock_obj.close() calls and maintaining existing messages;
reference the ProxyConnectError class, sock_obj close calls,
header_bytes/status_line parsing and the status_code extraction in your edits.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9d62cd8 and 818af01.

📒 Files selected for processing (1)
  • asabiris/output/smtp/service.py

Comment on lines +235 to +244
if self.ProxyHost:
proxy_socket = await self._connect_via_http_proxy()
send_kwargs["sock"] = proxy_socket
send_kwargs["hostname"] = None
send_kwargs["port"] = None
else:
send_kwargs["hostname"] = self.Host
send_kwargs["port"] = int(self.Port) if self.Port != "" else None

result = await aiosmtplib.send(msg, **send_kwargs)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

aiosmtplib send sock parameter hostname port documentation

💡 Result:

aiosmtplib.send() accepts both hostname/port and a sock parameter, but sock is mutually exclusive with port (and socket_path).

  • Signature (current docs): aiosmtplib.send(..., hostname='localhost', port=None, ..., socket_path=None, sock=None) [1]
  • sock meaning: “An existing, connected socket object… Passing a socket object will transfer control of it to the asyncio connection, and it will be closed when the client disconnects.” Also: not compatible with port or socket_path. [1]
  • The same rule applies to the lower-level SMTP(..., sock=...) / SMTP.connect(sock=...): if sock is given, you should not provide hostname, port, or socket_path (and socket_path is also incompatible with port/sock). [2]

Sources:
[1] aiosmtplib API reference (send coroutine parameters) (aiosmtplib.readthedocs.io)
[2] aiosmtplib smtp.py module docs/source (mutual exclusivity and connect behavior) (aiosmtplib.readthedocs.io)

Citations:


🏁 Script executed:

# Search for aiosmtplib import and send() usage in the repository
fd -e py -e txt | head -20 | xargs -I {} sh -c 'grep -l "aiosmtplib" {} 2>/dev/null'

Repository: TeskaLabs/asab-iris

Length of output: 47


🏁 Script executed:

# Check the actual file to see how send_kwargs is built and used
cat -n "asabiris/output/smtp/service.py" | sed -n '200,250p'

Repository: TeskaLabs/asab-iris

Length of output: 2016


🏁 Script executed:

# Search for aiosmtplib version requirements
fd -E node_modules \( -name "requirements*.txt" -o -name "setup.py" -o -name "pyproject.toml" -o -name "poetry.lock" -o -name "Pipfile" \) -type f

Repository: TeskaLabs/asab-iris

Length of output: 293


🏁 Script executed:

# Try to find aiosmtplib version or requirements
find . -type f \( -name "*.txt" -o -name "*.toml" -o -name "*.cfg" -o -name "*.py" \) | xargs grep -l "aiosmtplib" 2>/dev/null | head -5

Repository: TeskaLabs/asab-iris

Length of output: 115


🌐 Web query:

aiosmtplib send None port hostname values behavior mutually exclusive

💡 Result:

  • In aiosmtplib.send(...), the documented defaults are hostname="localhost" and port=None (where port then defaults to 465 if use_tls=True, 587 if start_tls=True, otherwise 25). [1]
  • If you explicitly pass hostname=None and you are not using sock or socket_path, the client will still replace it with "localhost" during connect(). [2]
  • If you explicitly pass port=None and you are not using sock or socket_path, the client will pick the default port (465/587/25) during connect(). [2]
  • Mutual-exclusion / incompatibility rules relevant to hostname/port:
    • sock and socket_path are mutually exclusive. [3]
    • If sock or socket_path is used, then port must be None (i.e., don’t provide a port). [3]
    • If sock/socket_path is used with use_tls=True, then hostname must not be None (it’s required for TLS SNI/cert handling). [3]
    • (send docs also state socket_path/sock are “not compatible with port”.) [1]

Sources: aiosmtplib API reference for send [1], and the SMTP.connect() / _validate_config() implementation in the docs-hosted source [2][3].


🏁 Script executed:

cat requirements.txt | grep aiosmtplib

Repository: TeskaLabs/asab-iris

Length of output: 83


🏁 Script executed:

# Check the SSL/TLS context and what hostname should be for proxy + TLS
cat -n "asabiris/output/smtp/service.py" | sed -n '220,245p'

Repository: TeskaLabs/asab-iris

Length of output: 1095


🏁 Script executed:

# Look for how self.Host and self.SSL are defined or documented
rg -A 3 -B 3 "(self\.SSL|self\.Host|self\.ProxyHost)" "asabiris/output/smtp/service.py" | head -40

Repository: TeskaLabs/asab-iris

Length of output: 1950


Set hostname to self.Host when using proxy with TLS enabled.

When using a pre-connected socket via HTTP proxy with TLS/SSL enabled (use_tls=True), aiosmtplib requires hostname to be set for TLS SNI (Server Name Indication) certificate negotiation. Currently, the code unconditionally sets hostname=None in the proxy path, which will fail at runtime when both proxy and SSL are configured.

The fix is to set hostname to the target SMTP server (self.Host) even when using a proxy socket:

if self.ProxyHost:
    proxy_socket = await self._connect_via_http_proxy()
    send_kwargs["sock"] = proxy_socket
    send_kwargs["hostname"] = self.Host  # Required for TLS SNI with proxy
    send_kwargs["port"] = None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 235 - 244, When a proxy socket
is used (self.ProxyHost true and socket created by _connect_via_http_proxy) the
code currently sets send_kwargs["hostname"] = None which breaks TLS SNI; change
the proxy branch so send_kwargs["sock"] = proxy_socket, send_kwargs["hostname"]
= self.Host (not None) and keep send_kwargs["port"] = None before calling
aiosmtplib.send(msg, **send_kwargs) so TLS certificate negotiation uses the
target host.

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

♻️ Duplicate comments (1)
asabiris/output/smtp/service.py (1)

177-185: ⚠️ Potential issue | 🟡 Minor

Avoid emitting an empty Cc header.

At Line 177, email_cc has already been normalized to [], so if email_cc is not None: is always true and can produce msg['Cc'] = ''.

Proposed fix
-		if email_cc is not None:
+		if email_cc:
 			assert isinstance(email_cc, list)
 			formatted_email_cc = []
 			for email_address in email_cc:
 				_, sender_email = self.format_sender_info(str(email_address))
 				if sender_email:
 					formatted_email_cc.append(sender_email)
 					cc_recipients.append(sender_email)
-			msg['Cc'] = ', '.join(formatted_email_cc)
+			if formatted_email_cc:
+				msg['Cc'] = ', '.join(formatted_email_cc)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 177 - 185, The Cc header can be
set to an empty string because email_cc is normalized to [] and the code uses
"if email_cc is not None:"; update the logic in the block that builds
formatted_email_cc (around variables email_cc, formatted_email_cc, cc_recipients
and the call to self.format_sender_info) to only assign msg['Cc'] when
formatted_email_cc is non-empty (e.g., check if formatted_email_cc before doing
msg['Cc'] = ', '.join(...)), and keep populating cc_recipients as before.
🧹 Nitpick comments (2)
asabiris/output/smtp/service.py (2)

222-223: Remove dead proxy_socket cleanup in retry loop.

proxy_socket is initialized but never assigned in this scope; cleanup here is unreachable after moving proxy handling into _send_via_proxy_smtp_client().

Proposed cleanup
-		for attempt in range(retry_attempts):
-			proxy_socket = None
+		for attempt in range(retry_attempts):
 			try:
 				if self.ProxyHost:
 					result = await self._send_via_proxy_smtp_client(
 						msg=msg,
 						sender=sender,
 						recipients=to_recipients + cc_recipients + bcc_recipients
 					)
...
-			finally:
-				if proxy_socket is not None:
-					try:
-						proxy_socket.close()
-					except OSError:
-						pass

Also applies to: 348-353

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 222 - 223, Remove the dead
proxy_socket variable and its cleanup inside the retry loop: delete the
proxy_socket = None initialization and any finally/cleanup block that checks or
closes proxy_socket within the retry logic, since proxy handling was moved into
_send_via_proxy_smtp_client(); instead rely on _send_via_proxy_smtp_client() to
open/close proxy sockets and ensure no leftover references to proxy_socket
remain in the retry loop (also remove the duplicate cleanup occurrence that
mirrors this pattern elsewhere in the same function).

387-395: Don't silently swallow cleanup failures.

At Lines 387-395, broad except Exception: pass hides operational signals during quit/close cleanup. Prefer scoped exceptions and debug logging.

Proposed refactor
 		finally:
 			if client is not None:
 				try:
 					await client.quit()
-				except Exception:
-					pass
+				except aiosmtplib.errors.SMTPException:
+					L.debug("Ignoring SMTP client quit failure", exc_info=True)
 			try:
 				proxy_socket.close()
-			except Exception:
-				pass
+			except OSError:
+				L.debug("Ignoring proxy socket close failure", exc_info=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 387 - 395, The cleanup code
currently swallows all exceptions from client.quit() and proxy_socket.close()
with bare "except Exception: pass"; change these to catch only appropriate
exceptions (e.g., smtplib.SMTPException or OSError/IOError) and avoid swallowing
asyncio.CancelledError/KeyboardInterrupt (re-raise them), and log any caught
error at debug or warning level using the module/class logger (referencing
client.quit() and proxy_socket.close()) so failures are visible during debugging
while non-operational exceptions propagate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@asabiris/output/smtp/service.py`:
- Around line 421-428: The current CONNECT-header reader (uses buffer,
loop.sock_recv, ProxyConnectError, max_headers_size) can over-read and drop SMTP
bytes arriving in the same recv; change the function so after detecting
b"\r\n\r\n" it splits buffer = buffer.split(b"\r\n\r\n", 1) and returns both the
header part and the leftover bytes (instead of only headers), or store the
leftover on the socket wrapper so callers (the SMTP handshake reader) can
consume the leftover bytes; update the function signature and its callers to
accept/handle (headers, remainder) so no SMTP bytes are discarded.

---

Duplicate comments:
In `@asabiris/output/smtp/service.py`:
- Around line 177-185: The Cc header can be set to an empty string because
email_cc is normalized to [] and the code uses "if email_cc is not None:";
update the logic in the block that builds formatted_email_cc (around variables
email_cc, formatted_email_cc, cc_recipients and the call to
self.format_sender_info) to only assign msg['Cc'] when formatted_email_cc is
non-empty (e.g., check if formatted_email_cc before doing msg['Cc'] = ',
'.join(...)), and keep populating cc_recipients as before.

---

Nitpick comments:
In `@asabiris/output/smtp/service.py`:
- Around line 222-223: Remove the dead proxy_socket variable and its cleanup
inside the retry loop: delete the proxy_socket = None initialization and any
finally/cleanup block that checks or closes proxy_socket within the retry logic,
since proxy handling was moved into _send_via_proxy_smtp_client(); instead rely
on _send_via_proxy_smtp_client() to open/close proxy sockets and ensure no
leftover references to proxy_socket remain in the retry loop (also remove the
duplicate cleanup occurrence that mirrors this pattern elsewhere in the same
function).
- Around line 387-395: The cleanup code currently swallows all exceptions from
client.quit() and proxy_socket.close() with bare "except Exception: pass";
change these to catch only appropriate exceptions (e.g., smtplib.SMTPException
or OSError/IOError) and avoid swallowing
asyncio.CancelledError/KeyboardInterrupt (re-raise them), and log any caught
error at debug or warning level using the module/class logger (referencing
client.quit() and proxy_socket.close()) so failures are visible during debugging
while non-operational exceptions propagate.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8e94464 and 17b0c86.

📒 Files selected for processing (1)
  • asabiris/output/smtp/service.py

Comment on lines +421 to +428
while b"\r\n\r\n" not in buffer:
chunk = await loop.sock_recv(sock_obj, 4096)
if not chunk:
raise ProxyConnectError("Proxy closed connection before CONNECT response")
buffer += chunk
if len(buffer) > max_headers_size:
raise ProxyConnectError("Proxy CONNECT response headers are too large")
return buffer.split(b"\r\n\r\n", 1)[0]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Proxy CONNECT header reader can drop SMTP bytes.

At Lines 421-428, reading in 4096-byte chunks and returning only the part before \r\n\r\n can discard bytes already belonging to the SMTP stream (if they arrive in the same recv). That can cause intermittent handshake/read failures.

Proposed fix (avoid over-read)
 	async def _read_http_headers(self, sock_obj):
 		loop = asyncio.get_running_loop()
 		buffer = b""
 		max_headers_size = 65536
-		while b"\r\n\r\n" not in buffer:
-			chunk = await loop.sock_recv(sock_obj, 4096)
+		header_terminator = b"\r\n\r\n"
+		while not buffer.endswith(header_terminator):
+			chunk = await loop.sock_recv(sock_obj, 1)
 			if not chunk:
 				raise ProxyConnectError("Proxy closed connection before CONNECT response")
 			buffer += chunk
 			if len(buffer) > max_headers_size:
 				raise ProxyConnectError("Proxy CONNECT response headers are too large")
-		return buffer.split(b"\r\n\r\n", 1)[0]
+		return buffer[:-len(header_terminator)]
🧰 Tools
🪛 Ruff (0.15.2)

[warning] 424-424: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 427-427: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 421 - 428, The current
CONNECT-header reader (uses buffer, loop.sock_recv, ProxyConnectError,
max_headers_size) can over-read and drop SMTP bytes arriving in the same recv;
change the function so after detecting b"\r\n\r\n" it splits buffer =
buffer.split(b"\r\n\r\n", 1) and returns both the header part and the leftover
bytes (instead of only headers), or store the leftover on the socket wrapper so
callers (the SMTP handshake reader) can consume the leftover bytes; update the
function signature and its callers to accept/handle (headers, remainder) so no
SMTP bytes are discarded.

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

🧹 Nitpick comments (2)
asabiris/output/smtp/service.py (2)

389-396: Avoid silent failures in proxy cleanup paths.

Swallowing exceptions without logging here makes intermittent socket/client shutdown issues hard to diagnose.

🔎 Suggested improvement
 		finally:
 			if client is not None:
 				try:
 					await client.quit()
-				except Exception:
-					pass
+				except Exception as e:
+					L.debug("Failed to quit SMTP proxy client: %s", e)
 			try:
 				proxy_socket.close()
-			except Exception:
-				pass
+			except Exception as e:
+				L.debug("Failed to close SMTP proxy socket: %s", e)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 389 - 396, The cleanup block
currently swallows exceptions from client.quit() and proxy_socket.close();
instead catch Exception as e and log the failure so shutdown issues are visible.
Update the try/except around client.quit() and proxy_socket.close() to log the
exception (e.g., logger.exception(...) or self.logger.error(..., exc_info=True))
including context like "error quitting SMTP client" and "error closing
proxy_socket", preserving the existing control flow after logging. Ensure you
reference the same logger used in this module/class (e.g., logger or
self.logger) so logs are consistent and searchable.

223-223: Remove dead proxy_socket cleanup in send().

proxy_socket is initialized but never assigned in this scope, so the finally close block is effectively dead code and misleading.

🧹 Suggested cleanup
 		for attempt in range(retry_attempts):
-			proxy_socket = None
 			try:
 				if self.ProxyHost:
 					result = await self._send_via_proxy_smtp_client(
 						msg=msg,
 						sender=sender,
 						recipients=to_recipients + cc_recipients + bcc_recipients
 					)
@@
-			finally:
-				if proxy_socket is not None:
-					try:
-						proxy_socket.close()
-					except OSError:
-						pass

Also applies to: 349-354

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` at line 223, In send() the local variable
proxy_socket is initialized but never assigned, and the finally block that
attempts to close it is dead/misleading; remove the unused proxy_socket
initialization and the finally/cleanup code that closes proxy_socket (or, if
proxy_socket is supposed to be created elsewhere, move its creation into this
scope and keep the cleanup). Apply the same change to the other similar block
referenced (around lines 349-354) so no unreachable proxy_socket cleanup
remains; look for proxy_socket references in send() and the other send-related
method and either remove the unused variable and its close call or properly
scope creation and cleanup together.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@asabiris/output/smtp/service.py`:
- Around line 72-77: The constructor currently only checks presence of
ProxyHost/ProxyPort but not their validity; update the initialization logic
around ProxyHost, ProxyPort and ProxyConnectTimeout (symbols: ProxyHost,
ProxyPort, ProxyConnectTimeout) to parse and validate proxy_port as an integer
in the valid TCP port range (1–65535) and ensure ProxyConnectTimeout is a
positive integer (>0); if parsing fails or values are out of range, raise
ValueError with a clear message so the service fails fast at startup. Apply the
same validation to the corresponding alternative initialization block referenced
around the other occurrence (lines ~438-440) to keep both code paths consistent.

---

Nitpick comments:
In `@asabiris/output/smtp/service.py`:
- Around line 389-396: The cleanup block currently swallows exceptions from
client.quit() and proxy_socket.close(); instead catch Exception as e and log the
failure so shutdown issues are visible. Update the try/except around
client.quit() and proxy_socket.close() to log the exception (e.g.,
logger.exception(...) or self.logger.error(..., exc_info=True)) including
context like "error quitting SMTP client" and "error closing proxy_socket",
preserving the existing control flow after logging. Ensure you reference the
same logger used in this module/class (e.g., logger or self.logger) so logs are
consistent and searchable.
- Line 223: In send() the local variable proxy_socket is initialized but never
assigned, and the finally block that attempts to close it is dead/misleading;
remove the unused proxy_socket initialization and the finally/cleanup code that
closes proxy_socket (or, if proxy_socket is supposed to be created elsewhere,
move its creation into this scope and keep the cleanup). Apply the same change
to the other similar block referenced (around lines 349-354) so no unreachable
proxy_socket cleanup remains; look for proxy_socket references in send() and the
other send-related method and either remove the unused variable and its close
call or properly scope creation and cleanup together.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 17b0c86 and c6cfce9.

📒 Files selected for processing (1)
  • asabiris/output/smtp/service.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.

♻️ Duplicate comments (1)
asabiris/output/smtp/service.py (1)

367-389: ⚠️ Potential issue | 🟠 Major

Proxy implicit-TLS path is still misconfigured for SNI/cert validation.

At Line 382, hostname=None is used with a pre-connected proxy socket. Combined with use_tls=self.SSL (Line 384), this can break TLS hostname/SNI behavior and is why secure config is being hard-blocked at Lines 367-375. Set hostname=self.Host in the SMTP client config and remove the forced insecure fallback.

🔧 Proposed fix
 	async def _send_via_proxy_smtp_client(self, *, msg, sender, recipients):
-		if self.SSL and self.ValidateCerts:
-			raise ASABIrisError(
-				ErrorCode.INVALID_SERVICE_CONFIGURATION,
-				tech_message=(
-					"Proxy with implicit TLS and certificate validation is not supported with current SMTP client "
-					"version. Use STARTTLS or disable certificate validation."
-				),
-				error_i18n_key="Unsupported SMTP proxy TLS configuration."
-			)
-
 		proxy_socket = await self._connect_via_http_proxy()
 		client = None
 		try:
 			client = aiosmtplib.SMTP(
 				sock=proxy_socket,
-				hostname=None,
+				hostname=self.Host,
 				port=None,
 				use_tls=self.SSL,
 				start_tls=False,
 				validate_certs=self.ValidateCerts,
 				cert_bundle=self.Cert or None,
 				timeout=self.ProxyConnectTimeout
 			)
In the latest aiosmtplib docs, for SMTP(sock=<connected socket>, use_tls=True), is hostname required/non-None for TLS/SNI and certificate validation?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 367 - 389, The SMTP client is
created with hostname=None which breaks TLS SNI and certificate validation when
using a pre-connected proxy socket; in the block that calls
self._connect_via_http_proxy() and constructs aiosmtplib.SMTP (currently passing
hostname=None and use_tls=self.SSL), replace hostname=None with
hostname=self.Host so the TLS handshake uses the real host for SNI/cert checks
(leave use_tls=self.SSL, validate_certs=self.ValidateCerts and
cert_bundle=self.Cert as-is); ensure this change is applied in the code that
constructs the SMTP client (the aiosmtplib.SMTP call) so the earlier strict
configuration check for implicit TLS will no longer be required.
🧹 Nitpick comments (3)
asabiris/output/smtp/service.py (3)

233-234: proxy_socket in send() is dead cleanup state.

proxy_socket is initialized in Line 233 but never assigned in this scope; the finally close block (Lines 359-364) is effectively no-op and can be removed for clarity.

Also applies to: 359-364

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 233 - 234, In send() the local
variable proxy_socket is initialized but never assigned or used, so the final
cleanup block that attempts to close proxy_socket is dead code; remove the
unused proxy_socket declaration and the corresponding finally/close block (the
finally that references proxy_socket) to simplify the function, or if proxy
behavior was intended, instead ensure proxy_socket is assigned where the proxy
is created (and closed in the finally), referencing the same proxy_socket symbol
consistently.

400-408: Cleanup failures are silently swallowed.

Lines 403-404 and 407-408 use broad except Exception: pass, which hides teardown issues during proxy SMTP failures.

🛠️ Suggested change
 		finally:
 			if client is not None:
 				try:
 					await client.quit()
-				except Exception:
-					pass
+				except Exception as e:
+					L.debug("SMTP proxy client quit failed: %s", e)
 			try:
 				proxy_socket.close()
-			except Exception:
-				pass
+			except Exception as e:
+				L.debug("Proxy socket close failed: %s", e)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@asabiris/output/smtp/service.py` around lines 400 - 408, The cleanup blocks
currently swallow all exceptions for client.quit() and proxy_socket.close();
replace the bare "except Exception: pass" with explicit exception capture and
logging so teardown failures are visible. In the block handling client (where
client is not None and await client.quit() is called) catch Exception as e and
call the module/class logger (e.g., logger.exception(...) or
processLogger.error(...) with the exception) to record the error and stacktrace;
do the same for proxy_socket.close() (catch Exception as e and log it). Ensure
you reference the existing logger variable used elsewhere in this module (or use
logging.getLogger(__name__) if none) and do not change the control flow beyond
logging (no silencing).

100-109: send() signature does not match the OutputABC contract, but actual callers have been updated.

The method violates the abstract base class (line 17 of asabiris/output_abc.py defines send(self, to, body_html, attachment=None, cc=None, bcc=None, subject=None)), yet all found callsites in asabiris/orchestration/sendemail.py already use the new keyword-only parameters (email_to, body, email_cc, etc.). No breaking positional calls or uses of old parameter names were found.

This signature mismatch is a design concern if SmtpService is used polymorphically through the OutputABC interface, but the practical breaking change risk is low since all existing callers have been updated.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@asabiris/output/smtp/service.py`:
- Around line 367-389: The SMTP client is created with hostname=None which
breaks TLS SNI and certificate validation when using a pre-connected proxy
socket; in the block that calls self._connect_via_http_proxy() and constructs
aiosmtplib.SMTP (currently passing hostname=None and use_tls=self.SSL), replace
hostname=None with hostname=self.Host so the TLS handshake uses the real host
for SNI/cert checks (leave use_tls=self.SSL, validate_certs=self.ValidateCerts
and cert_bundle=self.Cert as-is); ensure this change is applied in the code that
constructs the SMTP client (the aiosmtplib.SMTP call) so the earlier strict
configuration check for implicit TLS will no longer be required.

---

Nitpick comments:
In `@asabiris/output/smtp/service.py`:
- Around line 233-234: In send() the local variable proxy_socket is initialized
but never assigned or used, so the final cleanup block that attempts to close
proxy_socket is dead code; remove the unused proxy_socket declaration and the
corresponding finally/close block (the finally that references proxy_socket) to
simplify the function, or if proxy behavior was intended, instead ensure
proxy_socket is assigned where the proxy is created (and closed in the finally),
referencing the same proxy_socket symbol consistently.
- Around line 400-408: The cleanup blocks currently swallow all exceptions for
client.quit() and proxy_socket.close(); replace the bare "except Exception:
pass" with explicit exception capture and logging so teardown failures are
visible. In the block handling client (where client is not None and await
client.quit() is called) catch Exception as e and call the module/class logger
(e.g., logger.exception(...) or processLogger.error(...) with the exception) to
record the error and stacktrace; do the same for proxy_socket.close() (catch
Exception as e and log it). Ensure you reference the existing logger variable
used elsewhere in this module (or use logging.getLogger(__name__) if none) and
do not change the control flow beyond logging (no silencing).

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c6cfce9 and e1a8c5d.

📒 Files selected for processing (1)
  • asabiris/output/smtp/service.py

struct_data={"proxy_host": self.ProxyHost, "proxy_port": self.ProxyPort, "host": self.Host}
)
if attempt < retry_attempts - 1:
L.info("Retrying email send after proxy connection failure, attempt {}".format(attempt + 1))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Please use L.log(asab.LOG_NOTICE... instead of info

"host": self.Host,
}
)
except ASABIrisError:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Are these two lines needed here?

error_i18n_key="Unsupported SMTP proxy TLS configuration."
)

proxy_socket = await self._connect_via_http_proxy()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Please add comments here so people now that in order to use proxy a new socket needs to be created

@mithunbharadwaj mithunbharadwaj requested a review from ateska March 19, 2026 09:42
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.

2 participants