Skip to content
Closed
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
87 changes: 69 additions & 18 deletions gateway/platforms/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ def __init__(self, config: PlatformConfig):

# Map chat_id (sender email) -> last subject + message-id for threading
self._thread_context: Dict[str, Dict[str, str]] = {}
self._smtp_use_starttls: Optional[bool] = None

logger.info("[Email] Adapter initialized for %s", self._address)

Expand All @@ -270,6 +271,68 @@ def _trim_seen_uids(self) -> None:
# Fallback: just clear old entries if sort fails
self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:])

@staticmethod
def _close_smtp(smtp: smtplib.SMTP) -> None:
"""Close an SMTP connection, falling back to close() if quit() fails."""
try:
smtp.quit()
except Exception:
smtp.close()

def _smtp_supports_starttls(self) -> bool:
"""Probe whether this SMTP endpoint supports explicit STARTTLS."""
if self._smtp_use_starttls is not None:
return self._smtp_use_starttls

smtp: Optional[smtplib.SMTP] = None
try:
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
smtp.ehlo()
self._smtp_use_starttls = bool(smtp.has_extn("starttls"))
except Exception as e:
logger.info(
"[Email] STARTTLS probe failed for %s:%s, falling back to SMTP_SSL: %s",
self._smtp_host,
self._smtp_port,
e,
)
self._smtp_use_starttls = False
finally:
if smtp is not None:
self._close_smtp(smtp)

if self._smtp_use_starttls:
logger.info(
"[Email] SMTP endpoint %s:%s supports STARTTLS.",
self._smtp_host,
self._smtp_port,
)
else:
logger.info(
"[Email] SMTP endpoint %s:%s does not support STARTTLS; using SMTP_SSL.",
self._smtp_host,
self._smtp_port,
)
return self._smtp_use_starttls

def _connect_smtp(self) -> smtplib.SMTP:
"""Connect and authenticate using STARTTLS when supported, else SMTP_SSL."""
context = ssl.create_default_context()
if self._smtp_supports_starttls():
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
smtp.ehlo()
smtp.starttls(context=context)
smtp.ehlo()
else:
smtp = smtplib.SMTP_SSL(
self._smtp_host,
self._smtp_port,
timeout=30,
context=context,
)
smtp.login(self._address, self._password)
return smtp

async def connect(self) -> bool:
"""Connect to the IMAP server and start polling for new messages."""
try:
Expand All @@ -292,10 +355,8 @@ async def connect(self) -> bool:

try:
# Test SMTP connection
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.quit()
smtp = self._connect_smtp()
self._close_smtp(smtp)
logger.info("[Email] SMTP connection test passed.")
except Exception as e:
logger.error("[Email] SMTP connection failed: %s", e)
Expand Down Expand Up @@ -523,16 +584,11 @@ def _send_email(

msg.attach(MIMEText(body, "plain", "utf-8"))

smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
smtp = self._connect_smtp()
try:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
try:
smtp.quit()
except Exception:
smtp.close()
self._close_smtp(smtp)

logger.info("[Email] Sent reply to %s (subject: %s)", to_addr, subject)
return msg_id
Expand Down Expand Up @@ -724,16 +780,11 @@ def _send_email_with_attachment(
part.add_header("Content-Disposition", f"attachment; filename={fname}")
msg.attach(part)

smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
smtp = self._connect_smtp()
try:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
try:
smtp.quit()
except Exception:
smtp.close()
self._close_smtp(smtp)

return msg_id

Expand Down
87 changes: 86 additions & 1 deletion tests/gateway/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ def _make_adapter(self):
}):
from gateway.platforms.email import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
adapter._smtp_use_starttls = True
return adapter

def test_thread_context_stored_after_dispatch(self):
Expand Down Expand Up @@ -621,6 +622,7 @@ def _make_adapter(self):
}):
from gateway.platforms.email import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
adapter._smtp_use_starttls = True
return adapter

def test_send_calls_smtp(self):
Expand Down Expand Up @@ -657,6 +659,30 @@ def test_send_failure_returns_error(self):
self.assertFalse(result.success)
self.assertIn("Connection refused", result.error)

def test_send_falls_back_to_smtp_ssl_without_starttls(self):
"""send() should use SMTP_SSL when STARTTLS is unavailable."""
import asyncio
adapter = self._make_adapter()
adapter._smtp_use_starttls = None

probe_server = MagicMock()
probe_server.has_extn.return_value = False
ssl_server = MagicMock()

with patch("smtplib.SMTP", return_value=probe_server) as mock_smtp, \
patch("smtplib.SMTP_SSL", return_value=ssl_server) as mock_smtp_ssl:
result = asyncio.run(
adapter.send("user@test.com", "Hello from Hermes!")
)

self.assertTrue(result.success)
mock_smtp.assert_called_once()
probe_server.ehlo.assert_called_once()
mock_smtp_ssl.assert_called_once()
ssl_server.login.assert_called_once_with("hermes@test.com", "secret")
ssl_server.send_message.assert_called_once()
probe_server.starttls.assert_not_called()

def test_send_image_includes_url(self):
"""send_image should include image URL in email body."""
import asyncio
Expand Down Expand Up @@ -706,6 +732,36 @@ def test_send_document_with_attachment(self):
finally:
os.unlink(tmp_path)

def test_send_document_falls_back_to_smtp_ssl_without_starttls(self):
"""send_document() should use SMTP_SSL when STARTTLS is unavailable."""
import asyncio
import tempfile

adapter = self._make_adapter()
adapter._smtp_use_starttls = None

probe_server = MagicMock()
probe_server.has_extn.return_value = False
ssl_server = MagicMock()

with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
f.write(b"Test document content")
tmp_path = f.name

try:
with patch("smtplib.SMTP", return_value=probe_server), \
patch("smtplib.SMTP_SSL", return_value=ssl_server):
result = asyncio.run(
adapter.send_document("user@test.com", tmp_path, "Here is the file")
)

self.assertTrue(result.success)
ssl_server.login.assert_called_once_with("hermes@test.com", "secret")
ssl_server.send_message.assert_called_once()
probe_server.starttls.assert_not_called()
finally:
os.unlink(tmp_path)

def test_send_typing_is_noop(self):
"""send_typing should do nothing for email."""
import asyncio
Expand Down Expand Up @@ -741,6 +797,7 @@ def _make_adapter(self):
}):
from gateway.platforms.email import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
adapter._smtp_use_starttls = True
return adapter

def test_connect_success(self):
Expand Down Expand Up @@ -790,6 +847,32 @@ def test_connect_smtp_failure(self):
result = asyncio.run(adapter.connect())
self.assertFalse(result)

def test_connect_falls_back_to_smtp_ssl_without_starttls(self):
"""connect() should use SMTP_SSL when STARTTLS is unavailable."""
import asyncio

adapter = self._make_adapter()
adapter._smtp_use_starttls = None

mock_imap = MagicMock()
mock_imap.uid.return_value = ("OK", [b""])
probe_server = MagicMock()
probe_server.has_extn.return_value = False
ssl_server = MagicMock()

with patch("imaplib.IMAP4_SSL", return_value=mock_imap), \
patch("smtplib.SMTP", return_value=probe_server), \
patch("smtplib.SMTP_SSL", return_value=ssl_server) as mock_smtp_ssl:
result = asyncio.run(adapter.connect())

self.assertTrue(result)
mock_smtp_ssl.assert_called_once()
ssl_server.login.assert_called_once_with("hermes@test.com", "secret")
probe_server.starttls.assert_not_called()
adapter._running = False
if adapter._poll_task:
adapter._poll_task.cancel()

def test_disconnect_cancels_poll(self):
"""disconnect() should cancel the polling task."""
import asyncio
Expand Down Expand Up @@ -1027,7 +1110,9 @@ class TestSmtpConnectionCleanup(unittest.TestCase):
def _make_adapter(self):
from gateway.config import PlatformConfig
from gateway.platforms.email import EmailAdapter
return EmailAdapter(PlatformConfig(enabled=True))
adapter = EmailAdapter(PlatformConfig(enabled=True))
adapter._smtp_use_starttls = True
return adapter

@patch.dict(os.environ, {
"EMAIL_ADDRESS": "hermes@test.com",
Expand Down
Loading