IMAPClient 4.0.1__tar.gz → 4.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {imapclient-4.0.1 → imapclient-4.2.0}/AUTHORS.rst +1 -1
  2. {imapclient-4.0.1 → imapclient-4.2.0/IMAPClient.egg-info}/PKG-INFO +2 -1
  3. {imapclient-4.0.1 → imapclient-4.2.0}/IMAPClient.egg-info/SOURCES.txt +2 -0
  4. {imapclient-4.0.1/IMAPClient.egg-info → imapclient-4.2.0}/PKG-INFO +2 -1
  5. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/imapclient.py +49 -11
  6. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/tls.py +9 -3
  7. {imapclient-4.0.1 → imapclient-4.2.0}/pyproject.toml +3 -1
  8. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_imapclient.py +81 -6
  9. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_init.py +13 -0
  10. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_starttls.py +15 -18
  11. imapclient-4.2.0/tests/test_starttls_integration.py +162 -0
  12. imapclient-4.2.0/tests/test_tls_integration.py +133 -0
  13. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_util_functions.py +5 -0
  14. {imapclient-4.0.1 → imapclient-4.2.0}/COPYING +0 -0
  15. {imapclient-4.0.1 → imapclient-4.2.0}/IMAPClient.egg-info/dependency_links.txt +0 -0
  16. {imapclient-4.0.1 → imapclient-4.2.0}/IMAPClient.egg-info/entry_points.txt +0 -0
  17. {imapclient-4.0.1 → imapclient-4.2.0}/IMAPClient.egg-info/top_level.txt +0 -0
  18. {imapclient-4.0.1 → imapclient-4.2.0}/README.rst +0 -0
  19. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/__init__.py +0 -0
  20. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/config.py +0 -0
  21. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/datetime_util.py +0 -0
  22. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/exceptions.py +0 -0
  23. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/fixed_offset.py +0 -0
  24. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/imap4.py +0 -0
  25. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/imap_utf7.py +0 -0
  26. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/interact.py +0 -0
  27. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/livetest.py +0 -0
  28. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/response_lexer.py +0 -0
  29. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/response_parser.py +0 -0
  30. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/response_types.py +0 -0
  31. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/testable_imapclient.py +0 -0
  32. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/typing_imapclient.py +0 -0
  33. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/util.py +0 -0
  34. {imapclient-4.0.1 → imapclient-4.2.0}/imapclient/version.py +0 -0
  35. {imapclient-4.0.1 → imapclient-4.2.0}/setup.cfg +0 -0
  36. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_auth.py +0 -0
  37. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_datetime_util.py +0 -0
  38. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_enable.py +0 -0
  39. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_fixed_offset.py +0 -0
  40. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_folder_status.py +0 -0
  41. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_imap_utf7.py +0 -0
  42. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_response_lexer.py +0 -0
  43. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_response_parser.py +0 -0
  44. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_search.py +0 -0
  45. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_sort.py +0 -0
  46. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_store.py +0 -0
  47. {imapclient-4.0.1 → imapclient-4.2.0}/tests/test_thread.py +0 -0
@@ -48,4 +48,4 @@ project:
48
48
  - Sean Whalen
49
49
  - Peter Wienemann
50
50
  - Arnout Engelen
51
-
51
+ - Lars Artmann
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: IMAPClient
3
- Version: 4.0.1
3
+ Version: 4.2.0
4
4
  Summary: Easy-to-use, Pythonic and complete IMAP client library
5
5
  Author-email: Menno Finlay-Smits <hello@menno.io>
6
6
  Maintainer-email: IMAPClient Maintainers <imapclient@groups.io>
7
+ License: BSD-3-Clause
7
8
  Project-URL: Homepage, https://github.com/mjs/imapclient/
8
9
  Keywords: imap,client,email,mail
9
10
  Classifier: Development Status :: 5 - Production/Stable
@@ -38,6 +38,8 @@ tests/test_response_parser.py
38
38
  tests/test_search.py
39
39
  tests/test_sort.py
40
40
  tests/test_starttls.py
41
+ tests/test_starttls_integration.py
41
42
  tests/test_store.py
42
43
  tests/test_thread.py
44
+ tests/test_tls_integration.py
43
45
  tests/test_util_functions.py
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: IMAPClient
3
- Version: 4.0.1
3
+ Version: 4.2.0
4
4
  Summary: Easy-to-use, Pythonic and complete IMAP client library
5
5
  Author-email: Menno Finlay-Smits <hello@menno.io>
6
6
  Maintainer-email: IMAPClient Maintainers <imapclient@groups.io>
7
+ License: BSD-3-Clause
7
8
  Project-URL: Homepage, https://github.com/mjs/imapclient/
8
9
  Keywords: imap,client,email,mail
9
10
  Classifier: Development Status :: 5 - Production/Stable
@@ -42,6 +42,7 @@ __all__ = [
42
42
  "FLAGGED",
43
43
  "DRAFT",
44
44
  "RECENT",
45
+ "literal",
45
46
  ]
46
47
 
47
48
 
@@ -321,6 +322,21 @@ class IMAPClient:
321
322
 
322
323
  connect_timeout = getattr(self._timeout, "connect", None)
323
324
 
325
+ if sys.version_info < (3, 9):
326
+ # Support for Python 3.8
327
+ if connect_timeout:
328
+ raise ValueError(
329
+ "connection timeout is not supported with this version of Python"
330
+ )
331
+
332
+ if self.ssl:
333
+ return tls.IMAP4_TLS(
334
+ self.host,
335
+ self.port,
336
+ self.ssl_context,
337
+ )
338
+ return imap4.IMAP4WithTimeout(self.host, self.port)
339
+
324
340
  if self.ssl:
325
341
  return tls.IMAP4_TLS(
326
342
  self.host,
@@ -328,7 +344,6 @@ class IMAPClient:
328
344
  self.ssl_context,
329
345
  connect_timeout,
330
346
  )
331
-
332
347
  return imap4.IMAP4WithTimeout(self.host, self.port, connect_timeout)
333
348
 
334
349
  def _set_read_timeout(self):
@@ -378,13 +393,17 @@ class IMAPClient:
378
393
  if self.ssl or self._starttls_done:
379
394
  raise exceptions.IMAPClientAbortError("TLS session already established")
380
395
 
381
- typ, data = self._imap._simple_command("STARTTLS")
396
+ # Delegate the socket wrap to imaplib: it manages its own read buffer
397
+ # since Python 3.14 (where IMAP4.file became a read-only property).
398
+ # imaplib's own None-fallback context does not verify certificates, so
399
+ # pass IMAPClient's verifying default instead.
400
+ if ssl_context is None:
401
+ ssl_context = tls.create_default_context()
402
+
403
+ typ, data = self._imap.starttls(ssl_context=ssl_context)
382
404
  self._checkok("starttls", typ, data)
383
405
 
384
406
  self._starttls_done = True
385
-
386
- self._imap.sock = tls.wrap_socket(self._imap.sock, ssl_context, self.host)
387
- self._imap.file = self._imap.sock.makefile("rb")
388
407
  return data[0]
389
408
 
390
409
  def login(self, username: str, password: str):
@@ -1457,9 +1476,9 @@ class IMAPClient:
1457
1476
  yield to_bytes(seq_to_parenstr(m["flags"]))
1458
1477
  if "date" in m:
1459
1478
  yield to_bytes('"%s"' % datetime_to_INTERNALDATE(m["date"]))
1460
- yield _literal(to_bytes(m["msg"]))
1479
+ yield literal(to_bytes(m["msg"]))
1461
1480
  else:
1462
- yield _literal(to_bytes(m))
1481
+ yield literal(to_bytes(m))
1463
1482
 
1464
1483
  msgs = list(chunks())
1465
1484
 
@@ -1705,11 +1724,25 @@ class IMAPClient:
1705
1724
  prefix.append(b"UID")
1706
1725
  prefix.append(command)
1707
1726
 
1708
- line = []
1709
- for item, is_last in _iter_with_last(prefix + args):
1727
+ # Check every argument before anything goes on the wire: a bad
1728
+ # argument found after a literal was sent would leave the server
1729
+ # waiting inside a half-sent command.
1730
+ for item in itertools.chain(prefix, args):
1710
1731
  if not isinstance(item, bytes):
1711
1732
  raise ValueError("command args must be passed as bytes")
1733
+ if b"\x00" in item:
1734
+ raise ValueError("NUL is not allowed in command arguments")
1735
+ if not _is8bit(item) and (b"\r" in item or b"\n" in item):
1736
+ # CR and LF end the command line on the wire, so an argument
1737
+ # carrying them would be run as extra commands. A literal can
1738
+ # hold them, which is what the 8-bit path already sends.
1739
+ raise ValueError(
1740
+ "CR and LF are not allowed in command arguments; "
1741
+ "wrap the value in imapclient.literal to send it as a literal"
1742
+ )
1712
1743
 
1744
+ line = []
1745
+ for item, is_last in _iter_with_last(prefix + args):
1713
1746
  if _is8bit(item):
1714
1747
  # If a line was already started send it
1715
1748
  if line:
@@ -1868,6 +1901,8 @@ def _normalise_search_criteria(criteria, charset=None):
1868
1901
  inner[0] = b"(" + inner[0]
1869
1902
  inner[-1] = inner[-1] + b")"
1870
1903
  out.extend(inner) # flatten
1904
+ elif isinstance(item, literal):
1905
+ out.append(item)
1871
1906
  else:
1872
1907
  out.append(_quoted.maybe(to_bytes(item, charset)))
1873
1908
  return out
@@ -1879,10 +1914,13 @@ def _normalise_sort_criteria(criteria, charset=None):
1879
1914
  return b"(" + b" ".join(to_bytes(item).upper() for item in criteria) + b")"
1880
1915
 
1881
1916
 
1882
- class _literal(bytes):
1917
+ class literal(bytes):
1883
1918
  """Hold message data that should always be sent as a literal."""
1884
1919
 
1885
1920
 
1921
+ _literal = literal # old private name, kept for callers that imported it
1922
+
1923
+
1886
1924
  class _quoted(bytes):
1887
1925
  """
1888
1926
  This class holds a quoted bytes value which provides access to the
@@ -1975,7 +2013,7 @@ def as_triplets(items):
1975
2013
 
1976
2014
 
1977
2015
  def _is8bit(data):
1978
- return isinstance(data, _literal) or any(b > 127 for b in data)
2016
+ return isinstance(data, literal) or any(b > 127 for b in data)
1979
2017
 
1980
2018
 
1981
2019
  def _iter_with_last(items):
@@ -1,4 +1,4 @@
1
- # Copyright (c) 2023, Menno Smits
1
+ # Copyright (c) 2026, Menno Smits
2
2
  # Released subject to the New BSD License
3
3
  # Please see http://en.wikipedia.org/wiki/BSD_licenses
4
4
 
@@ -13,11 +13,15 @@ import ssl
13
13
  from typing import Optional
14
14
 
15
15
 
16
+ def create_default_context() -> ssl.SSLContext:
17
+ return ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
18
+
19
+
16
20
  def wrap_socket(
17
21
  sock: socket.socket, ssl_context: Optional[ssl.SSLContext], host: str
18
22
  ) -> socket.socket:
19
23
  if ssl_context is None:
20
- ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
24
+ ssl_context = create_default_context()
21
25
 
22
26
  return ssl_context.wrap_socket(sock, server_hostname=host)
23
27
 
@@ -39,7 +43,9 @@ class IMAP4_TLS(imaplib.IMAP4):
39
43
  self._timeout = timeout
40
44
  super().__init__(host, port)
41
45
 
42
- def _create_socket(self, timeout: Optional[float]) -> socket.socket:
46
+ def _create_socket(self, timeout: Optional[float] = None) -> socket.socket:
47
+ if timeout is None:
48
+ timeout = self._timeout
43
49
  sock = socket.create_connection((self.host, self.port), timeout=timeout)
44
50
 
45
51
  return wrap_socket(sock, self.ssl_context, self.host)
@@ -1,7 +1,8 @@
1
1
  [project]
2
2
  name = "IMAPClient"
3
3
  description = "Easy-to-use, Pythonic and complete IMAP client library"
4
- version = "4.0.1"
4
+ version = "4.2.0"
5
+ license = {text = "BSD-3-Clause"}
5
6
  authors = [
6
7
  {name = "Menno Finlay-Smits", email = "hello@menno.io"},
7
8
  ]
@@ -46,6 +47,7 @@ requires = ["setuptools>=61.0"]
46
47
  build-backend = "setuptools.build_meta"
47
48
 
48
49
  [tool.setuptools]
50
+ license-files = ["COPYING", "AUTHORS.rst"]
49
51
  packages = ["imapclient"]
50
52
 
51
53
  [tool.setuptools.package-data]
@@ -15,18 +15,59 @@ from unittest.mock import Mock, patch, sentinel
15
15
  from imapclient.exceptions import CapabilityError, IMAPClientError, ProtocolError
16
16
  from imapclient.fixed_offset import FixedOffset
17
17
  from imapclient.imapclient import (
18
- _literal,
19
18
  _parse_quota,
19
+ )
20
+ from imapclient.imapclient import IMAPClient as RealIMAPClient
21
+ from imapclient.imapclient import (
20
22
  IMAPlibLoggerAdapter,
23
+ literal,
21
24
  MailboxQuotaRoots,
22
25
  Quota,
23
26
  require_capability,
27
+ SocketTimeout,
24
28
  )
25
29
  from imapclient.testable_imapclient import TestableIMAPClient as IMAPClient
26
30
 
27
31
  from .imapclient_test import IMAPClientTest
28
32
 
29
33
 
34
+ class TestCreateIMAP4(IMAPClientTest):
35
+ @patch("imapclient.imapclient.sys.version_info", (3, 8, 0))
36
+ @patch("imapclient.imapclient.tls.IMAP4_TLS")
37
+ @patch("imapclient.imapclient.imap4.IMAP4WithTimeout")
38
+ def test_python_38_does_not_pass_connect_timeout(self, plain_imap, tls_imap):
39
+ self.client._timeout = SocketTimeout(None, 30)
40
+ self.client.ssl_context = sentinel.ssl_context
41
+
42
+ self.client.ssl = True
43
+ self.assertIs(RealIMAPClient._create_IMAP4(self.client), tls_imap.return_value)
44
+ tls_imap.assert_called_once_with("somehost", 993, sentinel.ssl_context)
45
+ plain_imap.assert_not_called()
46
+
47
+ self.client.ssl = False
48
+ self.assertIs(
49
+ RealIMAPClient._create_IMAP4(self.client), plain_imap.return_value
50
+ )
51
+ plain_imap.assert_called_once_with("somehost", 993)
52
+
53
+ @patch("imapclient.imapclient.sys.version_info", (3, 8, 0))
54
+ @patch("imapclient.imapclient.tls.IMAP4_TLS")
55
+ @patch("imapclient.imapclient.imap4.IMAP4WithTimeout")
56
+ def test_python_38_rejects_connect_timeout(self, plain_imap, tls_imap):
57
+ self.client._timeout = SocketTimeout(10, 30)
58
+
59
+ for use_ssl in (True, False):
60
+ with self.subTest(ssl=use_ssl):
61
+ self.client.ssl = use_ssl
62
+ with self.assertRaisesRegex(
63
+ ValueError, "connection timeout is not supported"
64
+ ):
65
+ RealIMAPClient._create_IMAP4(self.client)
66
+
67
+ tls_imap.assert_not_called()
68
+ plain_imap.assert_not_called()
69
+
70
+
30
71
  class TestListFolders(IMAPClientTest):
31
72
  def test_list_folders(self):
32
73
  self.client._imap._simple_command.return_value = ("OK", [b"something"])
@@ -416,11 +457,11 @@ class TestAppend(IMAPClientTest):
416
457
  b'"foobar"',
417
458
  b"(FLAG WAVE)",
418
459
  b'"05-Apr-2009 11:00:05 +0200"',
419
- _literal(b"msg1"),
460
+ literal(b"msg1"),
420
461
  b"(FLAG WAVE)",
421
- _literal(b"msg2"),
462
+ literal(b"msg2"),
422
463
  b'"05-Apr-2009 11:00:05 +0200"',
423
- _literal(b"msg3"),
464
+ literal(b"msg3"),
424
465
  ],
425
466
  uid=False,
426
467
  )
@@ -749,6 +790,40 @@ class TestIdleAndNoop(IMAPClientTest):
749
790
  self.assertListEqual([(99, b"EXISTS")], responses)
750
791
 
751
792
 
793
+ class TestRawCommandRejectsControlCharacters(IMAPClientTest):
794
+ def setUp(self):
795
+ super().setUp()
796
+ self.client._imap.send = Mock()
797
+ self.client._cached_capabilities = (b"IMAP4REV1",)
798
+
799
+ def test_crlf_in_a_line_argument_is_rejected_before_sending(self):
800
+ criterion = b'x"\r\nX1 STORE 1:* +FLAGS (\\Deleted)\r\nX2 EXPUNGE'
801
+
802
+ with self.assertRaises(ValueError):
803
+ self.client._raw_command(b"SEARCH", [b"HEADER", b"Message-ID", criterion])
804
+
805
+ self.client._imap.send.assert_not_called()
806
+
807
+ def test_crlf_via_search_criteria_is_rejected_before_sending(self):
808
+ with self.assertRaises(ValueError):
809
+ self.client.search(["HEADER", "Message-ID", 'x"\r\nX1 NOOP'])
810
+
811
+ self.client._imap.send.assert_not_called()
812
+
813
+ def test_nul_is_rejected_even_inside_a_literal(self):
814
+ with self.assertRaises(ValueError):
815
+ self.client._raw_command(b"SEARCH", [b"TEXT", literal(b"a\x00b")])
816
+
817
+ self.client._imap.send.assert_not_called()
818
+
819
+ def test_crlf_inside_a_literal_is_allowed(self):
820
+ self.client._send_literal = Mock()
821
+
822
+ self.client._raw_command(b"SEARCH", [b"TEXT", literal(b"line one\r\nline two")])
823
+
824
+ self.client._send_literal.assert_called_once()
825
+
826
+
752
827
  class TestDebugLogging(IMAPClientTest):
753
828
  def test_IMAP_is_patched(self):
754
829
  # Remove all logging handlers so that the order of tests does not
@@ -1006,7 +1081,7 @@ class TestRawCommand(IMAPClientTest):
1006
1081
  self.client._cached_capabilities = (b"LITERAL+",)
1007
1082
 
1008
1083
  typ, data = self.client._raw_command(
1009
- b"APPEND", [b"\xff", _literal(b"hello")], uid=False
1084
+ b"APPEND", [b"\xff", literal(b"hello")], uid=False
1010
1085
  )
1011
1086
  self.assertEqual(typ, "OK")
1012
1087
  self.assertEqual(data, ["done"])
@@ -1020,7 +1095,7 @@ class TestRawCommand(IMAPClientTest):
1020
1095
 
1021
1096
  typ, data = self.client._raw_command(
1022
1097
  b"APPEND",
1023
- [b"\xff", _literal(b"hello"), b"TEXT", _literal(b"test")],
1098
+ [b"\xff", literal(b"hello"), b"TEXT", literal(b"test")],
1024
1099
  uid=False,
1025
1100
  )
1026
1101
  self.assertEqual(typ, "OK")
@@ -2,6 +2,7 @@
2
2
  # Released subject to the New BSD License
3
3
  # Please see http://en.wikipedia.org/wiki/BSD_licenses
4
4
 
5
+ import sys
5
6
  import unittest
6
7
  from unittest.mock import Mock, patch, sentinel
7
8
 
@@ -22,6 +23,9 @@ class TestInit(unittest.TestCase):
22
23
  self.imaplib = patcher.start()
23
24
  self.addCleanup(patcher.stop)
24
25
 
26
+ @unittest.skipIf(
27
+ sys.version_info < (3, 9), "connection timeouts require Python 3.9+"
28
+ )
25
29
  def test_plain(self):
26
30
  fakeIMAP4 = Mock()
27
31
  self.imap4.IMAP4WithTimeout.return_value = fakeIMAP4
@@ -36,6 +40,9 @@ class TestInit(unittest.TestCase):
36
40
  self.assertEqual(imap.ssl_context, None)
37
41
  self.assertEqual(imap.stream, False)
38
42
 
43
+ @unittest.skipIf(
44
+ sys.version_info < (3, 9), "connection timeouts require Python 3.9+"
45
+ )
39
46
  def test_plain_SocketTimeout(self):
40
47
  fakeIMAP4 = Mock()
41
48
  self.imap4.IMAP4WithTimeout.return_value = fakeIMAP4
@@ -51,6 +58,9 @@ class TestInit(unittest.TestCase):
51
58
  "1.2.3.4", 143, sentinel.connect_timeout
52
59
  )
53
60
 
61
+ @unittest.skipIf(
62
+ sys.version_info < (3, 9), "connection timeouts require Python 3.9+"
63
+ )
54
64
  def test_SSL(self):
55
65
  fakeIMAP4_TLS = Mock()
56
66
  self.tls.IMAP4_TLS.return_value = fakeIMAP4_TLS
@@ -69,6 +79,9 @@ class TestInit(unittest.TestCase):
69
79
  self.assertEqual(imap.ssl_context, sentinel.context)
70
80
  self.assertEqual(imap.stream, False)
71
81
 
82
+ @unittest.skipIf(
83
+ sys.version_info < (3, 9), "connection timeouts require Python 3.9+"
84
+ )
72
85
  def test_SSL_SocketTimeout(self):
73
86
  fakeIMAP4_TLS = Mock()
74
87
  self.tls.IMAP4_TLS.return_value = fakeIMAP4_TLS
@@ -2,7 +2,7 @@
2
2
  # Released subject to the New BSD License
3
3
  # Please see http://en.wikipedia.org/wiki/BSD_licenses
4
4
 
5
- from unittest.mock import Mock, patch, sentinel
5
+ from unittest.mock import patch, sentinel
6
6
 
7
7
  from imapclient.exceptions import IMAPClientError
8
8
  from imapclient.imapclient import IMAPClient
@@ -18,34 +18,31 @@ class TestStarttls(IMAPClientTest):
18
18
  self.tls = patcher.start()
19
19
  self.addCleanup(patcher.stop)
20
20
 
21
- self.client._imap.sock = sentinel.old_sock
22
-
23
- self.new_sock = Mock()
24
- self.new_sock.makefile.return_value = sentinel.file
25
- self.tls.wrap_socket.return_value = self.new_sock
26
-
27
- self.client.host = sentinel.host
28
21
  self.client.ssl = False
29
22
  self.client._starttls_done = False
30
- self.client._imap._simple_command.return_value = "OK", [
31
- b"start TLS negotiation"
32
- ]
23
+ self.client._imap.starttls.return_value = "OK", [b"start TLS negotiation"]
33
24
  self.client._cached_capabilities = [b"STARTTLS"]
34
25
 
35
26
  def test_works(self):
36
27
  resp = self.client.starttls(sentinel.ssl_context)
37
28
 
38
- self.tls.wrap_socket.assert_called_once_with(
39
- sentinel.old_sock,
40
- sentinel.ssl_context,
41
- sentinel.host,
29
+ self.client._imap.starttls.assert_called_once_with(
30
+ ssl_context=sentinel.ssl_context
31
+ )
32
+ self.tls.create_default_context.assert_not_called()
33
+ self.assertTrue(self.client._starttls_done)
34
+ self.assertEqual(resp, b"start TLS negotiation")
35
+
36
+ def test_default_context_is_used(self):
37
+ resp = self.client.starttls()
38
+
39
+ self.client._imap.starttls.assert_called_once_with(
40
+ ssl_context=self.tls.create_default_context.return_value
42
41
  )
43
- self.new_sock.makefile.assert_called_once_with("rb")
44
- self.assertEqual(self.client._imap.file, sentinel.file)
45
42
  self.assertEqual(resp, b"start TLS negotiation")
46
43
 
47
44
  def test_command_fails(self):
48
- self.client._imap._simple_command.return_value = "NO", [b"sorry"]
45
+ self.client._imap.starttls.return_value = "NO", [b"sorry"]
49
46
 
50
47
  with self.assertRaises(IMAPClientError) as raised:
51
48
  self.client.starttls(sentinel.ssl_context)
@@ -0,0 +1,162 @@
1
+ # Copyright (c) 2025, Menno Smits
2
+ # Released subject to the New BSD License
3
+ # Please see http://en.wikipedia.org/wiki/BSD_licenses
4
+
5
+ """Integration tests for starttls() over a real socket pair.
6
+
7
+ These exercise the actual imaplib starttls code path (socket wrap plus
8
+ read-buffer refresh), which unit tests with mocked IMAP4 objects cannot
9
+ cover. This is the path that broke on Python 3.14, where IMAP4.file
10
+ became a read-only property.
11
+ """
12
+
13
+ import os
14
+ import socket
15
+ import ssl
16
+ import sys
17
+ import threading
18
+ import unittest
19
+ from pathlib import Path
20
+
21
+ from imapclient.exceptions import IMAPClientError
22
+ from imapclient.imapclient import IMAPClient
23
+
24
+ CERT_DIR = Path(__file__).parent / "certs"
25
+ CA_FILE = CERT_DIR / "ca.pem"
26
+ SERVER_CERT = CERT_DIR / "server.pem"
27
+ SERVER_KEY = CERT_DIR / "server.key"
28
+
29
+ PRE_TLS_CAPS = b"IMAP4rev1 STARTTLS AUTH=PLAIN"
30
+ POST_TLS_CAPS = b"IMAP4rev1 AUTH=PLAIN"
31
+
32
+
33
+ class FakeSTARTTLSServer:
34
+ """Minimal IMAP4 server that upgrades to TLS when told STARTTLS.
35
+
36
+ Serves one connection per instance. Post-TLS CAPABILITY responses omit
37
+ STARTTLS, mirroring what real servers advertise once the connection is
38
+ secured.
39
+ """
40
+
41
+ def __init__(self):
42
+ self.refuse_tls = False
43
+ self.error = None
44
+ self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
45
+ self.ctx.load_cert_chain(str(SERVER_CERT), str(SERVER_KEY))
46
+ self.sock = socket.socket()
47
+ self.sock.bind(("127.0.0.1", 0))
48
+ self.sock.listen(1)
49
+ self.port = self.sock.getsockname()[1]
50
+ self.thread = threading.Thread(target=self._serve, daemon=True)
51
+ self.thread.start()
52
+
53
+ def _reply_caps(self, conn, tag, caps):
54
+ conn.sendall(b"* CAPABILITY " + caps + b"\r\n")
55
+ conn.sendall(tag + b" OK CAPABILITY completed\r\n")
56
+
57
+ def _serve(self):
58
+ try:
59
+ conn, _ = self.sock.accept()
60
+ conn.sendall(
61
+ b"* OK [CAPABILITY " + PRE_TLS_CAPS + b"] fake server ready\r\n"
62
+ )
63
+ f = conn.makefile("rb")
64
+ while True:
65
+ line = f.readline()
66
+ if not line:
67
+ break
68
+ parts = line.split(None, 1)
69
+ tag = parts[0]
70
+ cmd = parts[1].strip().upper() if len(parts) > 1 else b""
71
+ if cmd.startswith(b"CAPABILITY"):
72
+ self._reply_caps(conn, tag, PRE_TLS_CAPS)
73
+ elif cmd.startswith(b"STARTTLS"):
74
+ if self.refuse_tls:
75
+ conn.sendall(tag + b" NO TLS refused\r\n")
76
+ continue
77
+ conn.sendall(tag + b" OK Begin TLS now\r\n")
78
+ self._serve_tls(self.ctx.wrap_socket(conn, server_side=True))
79
+ return
80
+ elif cmd.startswith(b"LOGOUT"):
81
+ conn.sendall(b"* BYE fake server signing off\r\n")
82
+ conn.sendall(tag + b" OK LOGOUT completed\r\n")
83
+ conn.close()
84
+ return
85
+ else:
86
+ conn.sendall(tag + b" OK done\r\n")
87
+ except Exception as e:
88
+ self.error = e
89
+
90
+ def _serve_tls(self, conn):
91
+ f = conn.makefile("rb")
92
+ while True:
93
+ line = f.readline()
94
+ if not line:
95
+ break
96
+ parts = line.split(None, 1)
97
+ tag = parts[0]
98
+ cmd = parts[1].strip().upper() if len(parts) > 1 else b""
99
+ if cmd.startswith(b"CAPABILITY"):
100
+ self._reply_caps(conn, tag, POST_TLS_CAPS)
101
+ elif cmd.startswith(b"LOGOUT"):
102
+ conn.sendall(b"* BYE fake server signing off\r\n")
103
+ conn.sendall(tag + b" OK LOGOUT completed\r\n")
104
+ conn.close()
105
+ return
106
+ else:
107
+ conn.sendall(tag + b" OK done\r\n")
108
+
109
+
110
+ @unittest.skipIf(
111
+ sys.version_info < (3, 9),
112
+ "imaplib.IMAP4 gained the timeout parameter in Python 3.9",
113
+ )
114
+ class StarttlsSocketTests(unittest.TestCase):
115
+ def setUp(self):
116
+ self.server = FakeSTARTTLSServer()
117
+ self._saved_cert_file = os.environ.pop("SSL_CERT_FILE", None)
118
+ os.environ["SSL_CERT_FILE"] = str(CA_FILE)
119
+
120
+ def tearDown(self):
121
+ if self._saved_cert_file is not None:
122
+ os.environ["SSL_CERT_FILE"] = self._saved_cert_file
123
+ self.server.sock.close()
124
+ self.assertIsNone(self.server.error)
125
+
126
+ def _client(self):
127
+ return IMAPClient("localhost", self.server.port, ssl=False)
128
+
129
+ def test_starttls_with_default_context(self):
130
+ client = self._client()
131
+
132
+ client.starttls()
133
+
134
+ sock = client._imap.sock
135
+ self.assertIsInstance(sock, ssl.SSLSocket)
136
+ # IMAPClient's documented default: hostname checking and certificate
137
+ # verification ON. imaplib's own None-fallback is unverified.
138
+ self.assertTrue(sock.context.check_hostname)
139
+ self.assertEqual(sock.context.verify_mode, ssl.CERT_REQUIRED)
140
+
141
+ self.assertNotIn(b"STARTTLS", client.capabilities())
142
+ client.login("user", "pass")
143
+ client.logout()
144
+
145
+ def test_starttls_with_custom_context(self):
146
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
147
+ context.check_hostname = False
148
+ context.verify_mode = ssl.CERT_NONE
149
+
150
+ client = self._client()
151
+ client.starttls(context)
152
+
153
+ self.assertIs(client._imap.sock.context, context)
154
+ client.login("user", "pass")
155
+ client.logout()
156
+
157
+ def test_starttls_refused_raises_imapclient_error(self):
158
+ self.server.refuse_tls = True
159
+ client = self._client()
160
+
161
+ with self.assertRaises(IMAPClientError):
162
+ client.starttls()
@@ -0,0 +1,133 @@
1
+ # Copyright (c) 2025, Menno Smits
2
+ # Released subject to the New BSD License
3
+ # Please see http://en.wikipedia.org/wiki/BSD_licenses
4
+
5
+ """Integration tests for IMAPClient(..., ssl=True) over a real socket pair.
6
+
7
+ These exercise the actual TLS-from-connect code path (tls.IMAP4_TLS socket
8
+ wrap), which unit tests with mocked IMAP4 objects cannot cover. They mirror
9
+ tests/test_starttls_integration.py, but with TLS established at connection
10
+ time instead of via the STARTTLS command.
11
+ """
12
+
13
+ import os
14
+ import socket
15
+ import ssl
16
+ import sys
17
+ import threading
18
+ import unittest
19
+ from pathlib import Path
20
+
21
+ from imapclient.imapclient import IMAPClient
22
+
23
+ CERT_DIR = Path(__file__).parent / "certs"
24
+ CA_FILE = CERT_DIR / "ca.pem"
25
+ SERVER_CERT = CERT_DIR / "server.pem"
26
+ SERVER_KEY = CERT_DIR / "server.key"
27
+
28
+ CAPS = b"IMAP4rev1 AUTH=PLAIN"
29
+
30
+
31
+ class FakeTLSServer:
32
+ """Minimal IMAP4 server that speaks TLS from the moment it accepts.
33
+
34
+ Serves one connection per instance. TLS handshake failures (expected
35
+ when the client rejects the server certificate) are recorded in
36
+ tls_error rather than error so tearDown can ignore them.
37
+ """
38
+
39
+ def __init__(self):
40
+ self.error = None
41
+ self.tls_error = None
42
+ self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
43
+ self.ctx.load_cert_chain(str(SERVER_CERT), str(SERVER_KEY))
44
+ self.sock = socket.socket()
45
+ self.sock.bind(("127.0.0.1", 0))
46
+ self.sock.listen(1)
47
+ self.port = self.sock.getsockname()[1]
48
+ self.thread = threading.Thread(target=self._serve, daemon=True)
49
+ self.thread.start()
50
+
51
+ def _serve(self):
52
+ try:
53
+ conn, _ = self.sock.accept()
54
+ try:
55
+ conn = self.ctx.wrap_socket(conn, server_side=True)
56
+ except ssl.SSLError as e:
57
+ self.tls_error = e
58
+ conn.close()
59
+ return
60
+ conn.sendall(b"* OK [CAPABILITY " + CAPS + b"] fake server ready\r\n")
61
+ f = conn.makefile("rb")
62
+ while True:
63
+ line = f.readline()
64
+ if not line:
65
+ break
66
+ parts = line.split(None, 1)
67
+ tag = parts[0]
68
+ cmd = parts[1].strip().upper() if len(parts) > 1 else b""
69
+ if cmd.startswith(b"CAPABILITY"):
70
+ conn.sendall(b"* CAPABILITY " + CAPS + b"\r\n")
71
+ conn.sendall(tag + b" OK CAPABILITY completed\r\n")
72
+ elif cmd.startswith(b"LOGOUT"):
73
+ conn.sendall(b"* BYE fake server signing off\r\n")
74
+ conn.sendall(tag + b" OK LOGOUT completed\r\n")
75
+ conn.close()
76
+ return
77
+ else:
78
+ conn.sendall(tag + b" OK done\r\n")
79
+ except Exception as e:
80
+ self.error = e
81
+
82
+
83
+ @unittest.skipIf(
84
+ sys.version_info < (3, 9),
85
+ "imaplib.IMAP4 gained the timeout parameter in Python 3.9",
86
+ )
87
+ class TlsSocketTests(unittest.TestCase):
88
+ def setUp(self):
89
+ self.server = FakeTLSServer()
90
+ self._saved_cert_file = os.environ.pop("SSL_CERT_FILE", None)
91
+ os.environ["SSL_CERT_FILE"] = str(CA_FILE)
92
+
93
+ def tearDown(self):
94
+ if self._saved_cert_file is not None:
95
+ os.environ["SSL_CERT_FILE"] = self._saved_cert_file
96
+ self.server.sock.close()
97
+ self.assertIsNone(self.server.error)
98
+
99
+ def _client(self, **kwargs):
100
+ return IMAPClient("localhost", self.server.port, ssl=True, **kwargs)
101
+
102
+ def test_ssl_connect_with_default_context(self):
103
+ client = self._client()
104
+
105
+ sock = client._imap.sock
106
+ self.assertIsInstance(sock, ssl.SSLSocket)
107
+ # IMAPClient's documented default: hostname checking and certificate
108
+ # verification ON. imaplib's own None-fallback is unverified.
109
+ self.assertTrue(sock.context.check_hostname)
110
+ self.assertEqual(sock.context.verify_mode, ssl.CERT_REQUIRED)
111
+
112
+ self.assertIn(b"IMAP4REV1", client.capabilities())
113
+ client.login("user", "pass")
114
+ client.logout()
115
+
116
+ def test_ssl_connect_with_custom_context(self):
117
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
118
+ context.check_hostname = False
119
+ context.verify_mode = ssl.CERT_NONE
120
+
121
+ client = self._client(ssl_context=context)
122
+
123
+ self.assertIs(client._imap.sock.context, context)
124
+ client.login("user", "pass")
125
+ client.logout()
126
+
127
+ def test_ssl_connect_untrusted_cert_fails(self):
128
+ # Without the test CA in the verification path, the default context
129
+ # must reject the server's self-signed certificate.
130
+ del os.environ["SSL_CERT_FILE"]
131
+
132
+ with self.assertRaises(ssl.SSLError):
133
+ self._client()
@@ -9,6 +9,7 @@ from imapclient.imapclient import (
9
9
  _normalise_search_criteria,
10
10
  _quoted,
11
11
  join_message_ids,
12
+ literal,
12
13
  normalise_text_list,
13
14
  seq_to_parenstr,
14
15
  seq_to_parenstr_upper,
@@ -139,6 +140,10 @@ class Test_normalise_search_criteria(unittest.TestCase):
139
140
  def test_quoting(self):
140
141
  self.check(["foo bar"], None, [_quoted(b'"foo bar"')])
141
142
 
143
+ def test_literal_is_not_quoted(self):
144
+ value = literal(b"line one\r\nline two")
145
+ self.check(["TEXT", value], None, [b"TEXT", value])
146
+
142
147
  def test_ints(self):
143
148
  self.check(["modseq", 500], None, [b"modseq", b"500"])
144
149
 
File without changes
File without changes
File without changes