Skip to content

Commit 37e199f

Browse files
committed
Fix two bugs that misreport why a run could not connect
A TLS failure was reported as an unreachable host. ssl.SSLError is a subclass of OSError, so the reachability handler in connect() - listed first - caught every one of them and the TLS branch behind it was dead code. Pointing the tool at a plaintext port (143 instead of 993), or at a server with an expired or untrusted certificate, printed "Could not reach host:port" and told the user to check an internet connection that was working fine, never mentioning the port they had actually mistyped. The "TLS handshake with ... failed" error the README documents in Troubleshooting could not be produced at all. Catch ssl.SSLError first, and name 993 in the hint since a wrong port is the usual cause. A quoted .env value with a trailing comment kept its quotes. _clean_value took the quoted branch only when the line *ended* on the closing quote, so an ordinary annotated line - EMAIL_CLEANER_PASSWORD="abcd efgh" # gmail app password - matched neither branch: the inline-comment strip ran instead and left the quotes in. IMAP then got a password with two stray '"' characters and rejected it, and the user was told their app password was wrong. Parse the quoted case up to its closing quote and treat whatever follows as the comment it is. Adds regression tests for both, including that an unreachable host and a rejected password still report as themselves.
1 parent 24919d9 commit 37e199f

3 files changed

Lines changed: 89 additions & 7 deletions

File tree

‎email_cleaner/config.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,19 @@ def _clean_value(value: str) -> str:
4545
A quoted value keeps everything between the quotes verbatim, so a '#' or
4646
spaces in a password survive. An unquoted value has a trailing inline
4747
'# comment' stripped, matching how people annotate a .env.
48+
49+
The quoted case ends at its closing quote rather than at the end of the
50+
line. Requiring the line to *finish* on that quote meant a perfectly
51+
ordinary annotated line - PASSWORD="a b c" # gmail app password - matched
52+
neither branch: the quotes stayed in, and IMAP got a password with two
53+
stray '"' in it and rejected it as simply wrong.
4854
"""
4955
value = value.strip()
50-
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
51-
return value[1:-1]
56+
quote = value[:1]
57+
if quote in ("'", '"'):
58+
end = value.find(quote, 1)
59+
if end != -1:
60+
return value[1:end] # anything past the closing quote is a comment
5261
marker = value.find(" #") # inline comment must have space before the hash
5362
if marker != -1:
5463
value = value[:marker]

‎email_cleaner/imap_client.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,16 +242,25 @@ def connect(self) -> None:
242242
self._imap = imaplib.IMAP4_SSL(
243243
self.host, self.port, ssl_context=ssl.create_default_context(), timeout=30
244244
)
245+
# ssl.SSLError is a subclass of OSError, so it has to be caught first.
246+
# Behind the reachability handler it was dead code, and every TLS
247+
# failure - a plaintext port like 143 answering the handshake, an
248+
# expired or untrusted certificate - came out as "could not reach
249+
# host", telling the user to check an internet connection that was
250+
# working fine and never mentioning the port they had actually mistyped.
251+
except ssl.SSLError as exc:
252+
raise CleanerError(
253+
f"TLS handshake with {self.host} failed ({exc}).",
254+
hint=(
255+
"The server may not support implicit TLS on this port. "
256+
"Most IMAP servers use 993; try --port 993."
257+
),
258+
) from exc
245259
except (socket.gaierror, TimeoutError, OSError) as exc:
246260
raise CleanerError(
247261
f"Could not reach {self.host}:{self.port} ({exc}).",
248262
hint="Check your internet connection and the IMAP host name.",
249263
) from exc
250-
except ssl.SSLError as exc:
251-
raise CleanerError(
252-
f"TLS handshake with {self.host} failed ({exc}).",
253-
hint="The server may not support implicit TLS on this port.",
254-
) from exc
255264

256265
try:
257266
self._imap.login(self.address, self._password)

‎tests/test_units.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import imaplib
77
import json
88
import os
9+
import socket
10+
import ssl
911
import sys
1012
import tempfile
1113
import unittest
@@ -499,6 +501,47 @@ def test_a_server_that_really_has_nothing_is_believed(self):
499501
self.assertFalse(session.supports_gmail_search)
500502

501503

504+
class TestConnectErrors(unittest.TestCase):
505+
"""ssl.SSLError subclasses OSError, so catching the latter first swallows
506+
every TLS failure and blames the network for a wrong port or a bad cert."""
507+
508+
def _connect_with(self, exc):
509+
session = ImapSession("mail.example.com", 143, "me@x.com", "pw")
510+
with mock.patch("imaplib.IMAP4_SSL", side_effect=exc):
511+
with self.assertRaises(CleanerError) as caught:
512+
session.connect()
513+
return caught.exception
514+
515+
def test_tls_failure_is_reported_as_a_tls_failure(self):
516+
err = self._connect_with(ssl.SSLError("WRONG_VERSION_NUMBER"))
517+
self.assertIn("TLS handshake", str(err))
518+
self.assertIn("993", err.hint)
519+
520+
def test_a_bad_certificate_is_a_tls_failure_too(self):
521+
err = self._connect_with(ssl.SSLCertVerificationError("certificate verify failed"))
522+
self.assertIn("TLS handshake", str(err))
523+
524+
def test_an_unreachable_host_still_reports_reachability(self):
525+
for exc in (
526+
socket.gaierror("name or service not known"),
527+
TimeoutError("timed out"),
528+
ConnectionRefusedError("refused"),
529+
):
530+
with self.subTest(exc=type(exc).__name__):
531+
err = self._connect_with(exc)
532+
self.assertIn("Could not reach", str(err))
533+
self.assertNotIn("TLS", str(err))
534+
535+
def test_a_rejected_password_is_still_a_login_error(self):
536+
session = ImapSession("mail.example.com", 993, "me@x.com", "pw")
537+
conn = mock.Mock()
538+
conn.login.side_effect = imaplib.IMAP4.error("AUTHENTICATIONFAILED")
539+
with mock.patch("imaplib.IMAP4_SSL", return_value=conn):
540+
with self.assertRaises(CleanerError) as caught:
541+
session.connect()
542+
self.assertIn("Login failed", str(caught.exception))
543+
544+
502545
class TestSessionTeardown(unittest.TestCase):
503546
"""IMAP CLOSE purges every \\Deleted message in a writable mailbox on its
504547
way out - the same unscoped wipe UID EXPUNGE exists to avoid. 'clean' opens
@@ -689,6 +732,27 @@ def test_hash_inside_quoted_value_is_kept(self):
689732
config.load_dotenv(self._write('EMAIL_CLEANER_PASSWORD="a # b"\n'))
690733
self.assertEqual(os.environ["EMAIL_CLEANER_PASSWORD"], "a # b")
691734

735+
def test_quoted_value_with_a_trailing_comment_loses_its_quotes(self):
736+
# the quotes used to survive into the value, because the line no longer
737+
# *ended* on one - so IMAP got a password with two stray '"' in it and
738+
# the user was told their app password was wrong
739+
config.load_dotenv(
740+
self._write('EMAIL_CLEANER_PASSWORD="abcd efgh" # gmail app password\n')
741+
)
742+
self.assertEqual(os.environ["EMAIL_CLEANER_PASSWORD"], "abcd efgh")
743+
744+
def test_single_quoted_value_with_a_trailing_comment(self):
745+
config.load_dotenv(self._write("EMAIL_CLEANER_HOST='h.example.com' # main\n"))
746+
self.assertEqual(os.environ["EMAIL_CLEANER_HOST"], "h.example.com")
747+
748+
def test_a_hash_after_the_closing_quote_is_a_comment_one_inside_is_not(self):
749+
config.load_dotenv(self._write('EMAIL_CLEANER_PASSWORD="a # b" # note\n'))
750+
self.assertEqual(os.environ["EMAIL_CLEANER_PASSWORD"], "a # b")
751+
752+
def test_unterminated_quote_is_left_alone(self):
753+
config.load_dotenv(self._write('EMAIL_CLEANER_HOST="h.example.com\n'))
754+
self.assertEqual(os.environ["EMAIL_CLEANER_HOST"], '"h.example.com')
755+
692756
def test_real_env_is_not_overwritten(self):
693757
os.environ["EMAIL_CLEANER_EMAIL"] = "real@env.com"
694758
config.load_dotenv(self._write("EMAIL_CLEANER_EMAIL=file@env.com\n"))

0 commit comments

Comments
 (0)