diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index df2468f7124e6d..6ebdbfa5e871b3 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -15,9 +15,9 @@ This module defines three classes, :class:`IMAP4`, :class:`IMAP4_SSL` and :class:`IMAP4_stream`, which encapsulate a connection to an IMAP4 server and -implement a large subset of the IMAP4rev1 client protocol as defined in -:rfc:`2060`. It is backward compatible with IMAP4 (:rfc:`1730`) servers, but -note that the ``STATUS`` command is not supported in IMAP4. +implement IMAP4rev1 and IMAP4rev2 client protocol features. It is backward +compatible with IMAP4 (:rfc:`1730`) servers, but note that the ``STATUS`` +command is not supported in IMAP4. .. include:: ../includes/wasm-notavail.rst @@ -28,7 +28,7 @@ base class: .. class:: IMAP4(host='', port=IMAP4_PORT, timeout=None) This class implements the actual IMAP4 protocol. The connection is created and - protocol version (IMAP4 or IMAP4rev1) is determined when the instance is + protocol version (IMAP4, IMAP4rev1, or IMAP4rev2) is determined when the instance is initialized. If *host* is not specified, ``''`` (the local host) is used. If *port* is omitted, the standard IMAP4 port (143) is used. The optional *timeout* parameter specifies a timeout in seconds for the connection attempt. @@ -169,8 +169,8 @@ example of usage. IMAP4 Objects ------------- -All IMAP4rev1 commands are represented by methods of the same name, either -uppercase or lowercase. +All IMAP4rev2 and IMAP4rev1 commands are represented by methods of the +same name, either uppercase or lowercase. All arguments to commands are converted to strings, except for ``AUTHENTICATE``, and the last argument to ``APPEND`` which is passed as an IMAP4 literal. If @@ -260,8 +260,10 @@ An :class:`IMAP4` instance has the following methods: .. method:: IMAP4.enable(capability) Enable *capability* (see :rfc:`5161`). Most capabilities do not need to be - enabled. Currently only the ``UTF8=ACCEPT`` capability is supported - (see :RFC:`6855`). + enabled. ``UTF8=ACCEPT`` has special client-side handling + (see :RFC:`6855`). On servers that advertise both ``IMAP4rev1`` and + ``IMAP4rev2``, ``ENABLE IMAP4REV2`` switches the connection from its + initial IMAP4rev1-compatible mode into IMAP4rev2 mode. .. versionadded:: 3.5 The :meth:`enable` method itself, and :RFC:`6855` support. @@ -507,7 +509,9 @@ An :class:`IMAP4` instance has the following methods: protocol requires that at least one criterion be specified; an exception will be raised when the server returns an error. *charset* must be ``None`` if the ``UTF8=ACCEPT`` capability was enabled using the :meth:`enable` - command. + command; :rfc:`6855` requires this once UTF-8 support has been enabled. + In IMAP4rev2 mode (:rfc:`9051`), UTF-8 is already in use, so specifying an explicit + ``CHARSET`` such as ``UTF-8`` is redundant, which implies that *charset* need not be ``None``. Example:: @@ -524,6 +528,11 @@ An :class:`IMAP4` instance has the following methods: (``EXISTS`` response). The default *mailbox* is ``'INBOX'``. If the *readonly* flag is set, modifications to the mailbox are not allowed. + With the :rfc:`6855` ``UTF8=ACCEPT`` extension, the server can open + mailboxes containing internationalized messages with ``SELECT`` and + ``EXAMINE``. In IMAP4rev2 mode, UTF-8 support is part of the protocol + instead of being enabled separately. + .. method:: IMAP4.send(data) @@ -681,8 +690,9 @@ The following attributes are defined on instances of :class:`IMAP4`: .. attribute:: IMAP4.PROTOCOL_VERSION - The most recent supported protocol in the ``CAPABILITY`` response from the - server. + The protocol version currently in use for the connection. If the server + advertises both ``IMAP4rev1`` and ``IMAP4rev2``, the connection starts in + ``IMAP4rev1`` mode until ``ENABLE IMAP4REV2`` is issued successfully. .. attribute:: IMAP4.debug @@ -695,7 +705,7 @@ The following attributes are defined on instances of :class:`IMAP4`: Boolean value that is normally ``False``, but is set to ``True`` if an :meth:`enable` command is successfully issued for the ``UTF8=ACCEPT`` - capability. + capability, and also when the connection enters IMAP4rev2 mode. .. versionadded:: 3.5 diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 2fafd9322c609e..d6aac41cf6916b 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -1,6 +1,6 @@ """IMAP4 client. -Based on RFC 2060. +Based on RFC 2060 and updated for RFC 9051. Public class: IMAP4 Public variable: Debug @@ -40,7 +40,11 @@ Debug = 0 IMAP4_PORT = 143 IMAP4_SSL_PORT = 993 -AllowedVersions = ('IMAP4REV1', 'IMAP4') # Most recent first +AllowedVersions = ('IMAP4REV2', 'IMAP4REV1', 'IMAP4') # Most recent first +IMAP4REV2_BUILTIN_COMMANDS = ( + 'NAMESPACE', 'UNSELECT', 'UIDPLUS', 'ESEARCH', 'SEARCHRES', + 'ENABLE', 'IDLE', 'SASL-IR', 'LIST-EXTENDED', 'LIST-STATUS', + 'MOVE', 'LITERAL+') # RFC 9051 Appendix E.2 # Maximal line length when calling readline(). This is to prevent # reading arbitrary length lines. RFC 3501 and 2060 (IMAP 4rev1) @@ -144,7 +148,7 @@ class IMAP4: If timeout is not given or is None, the global default socket timeout is used - All IMAP4rev1 commands are supported by methods of the same + All IMAP4rev2 and IMAP4rev1 commands are supported by methods of the same name (in lowercase). All arguments to commands are converted to strings, except for @@ -198,6 +202,7 @@ def __init__(self, host='', port=IMAP4_PORT, timeout=None): self.is_readonly = False # READ-ONLY desired state self.tagnum = 0 self._tls_established = False + self.PROTOCOL_VERSION = None self._mode_ascii() self._readbuf = [] @@ -260,14 +265,59 @@ def _connect(self): if self.debug >= 3: self._mesg('CAPABILITIES: %r' % (self.capabilities,)) + self._set_protocol_version() + + + def _set_protocol_version(self): + + if self._should_use_rev1_mode_for_dual_support(): + self._activate_rev1_mode() + return + for version in AllowedVersions: if not version in self.capabilities: continue - self.PROTOCOL_VERSION = version + if version == 'IMAP4REV2': + self._activate_rev2_mode() + else: + self.PROTOCOL_VERSION = version return raise self.error('server not IMAP4 compliant') + def _supports_rev2(self): + return 'IMAP4REV2' in self.capabilities + + def _supports_dual_rev1_rev2(self): + return self._supports_rev2() and 'IMAP4REV1' in self.capabilities + + def _is_using_rev2(self): + return self.PROTOCOL_VERSION == 'IMAP4REV2' + + def _is_capability_available(self, capability): + capability = capability.upper() + if self._is_using_rev2() and capability in IMAP4REV2_BUILTIN_COMMANDS: + return True + return capability in self.capabilities + + def _should_use_rev1_mode_for_dual_support(self): + return self._supports_dual_rev1_rev2() + + def _activate_rev1_mode(self): + self.PROTOCOL_VERSION = 'IMAP4REV1' + self._mode_ascii() + + def _activate_rev2_mode(self): + self.PROTOCOL_VERSION = 'IMAP4REV2' + self._mode_utf8() + + def _handle_enable_success(self, capability): + capability = capability.upper() + if 'UTF8=ACCEPT' in capability: + self._mode_utf8() + if 'IMAP4REV2' in capability: + self._activate_rev2_mode() + def __getattr__(self, attr): # Allow UPPERCASE variants of IMAP4 command methods. @@ -603,11 +653,11 @@ def enable(self, capability): (typ, [data]) = .enable(capability) """ - if 'ENABLE' not in self.capabilities: + if not self._is_capability_available('ENABLE'): raise IMAP4.error("Server does not support ENABLE") typ, data = self._simple_command('ENABLE', capability) - if typ == 'OK' and 'UTF8=ACCEPT' in capability.upper(): - self._mode_utf8() + if typ == 'OK': + self._handle_enable_success(capability) return typ, data def expunge(self): @@ -838,7 +888,7 @@ def search(self, charset, *criteria): """ name = 'SEARCH' if charset: - if self.utf8_enabled: + if self.utf8_enabled and not self._is_using_rev2(): raise IMAP4.error("Non-None charset not valid in UTF8 mode") typ, dat = self._simple_command(name, 'CHARSET', charset, *criteria) else: @@ -936,6 +986,7 @@ def starttls(self, ssl_context=None): self._imaplib_file = self.sock.makefile('rb') self._tls_established = True self._get_capabilities() + self._set_protocol_version() else: raise self.error("Couldn't establish TLS session") return self._untagged_response(typ, dat, name) @@ -1439,7 +1490,7 @@ class Idler: """ def __init__(self, imap, duration=None): - if 'IDLE' not in imap.capabilities: + if not imap._is_capability_available('IDLE'): raise imap.error("Server does not support IMAP4 IDLE") if duration is not None and not imap.sock: # IMAP4_stream pipes don't support timeouts diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 0b704d62655762..3b0f5f60b5054c 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -116,6 +116,8 @@ class SimpleIMAPHandler(socketserver.StreamRequestHandler): timeout = support.LOOPBACK_TIMEOUT continuation = None capabilities = '' + welcome = 'IMAP4rev1' + protocol_capabilities = 'IMAP4rev1' def setup(self): super().setup() @@ -136,9 +138,9 @@ def _send_textline(self, message): def _send_tagged(self, tag, code, message): self._send_textline(' '.join((tag, code, message))) - def handle(self): + def handle(self): # Send a welcome message. - self._send_textline('* OK IMAP4rev1') + self._send_textline(f'* OK {self.welcome}') while 1: # Gather up input until we receive a line terminator or we timeout. # Accumulate read(1) because it's simpler to handle the differences @@ -179,9 +181,7 @@ def handle(self): self._send_tagged(tag, 'BAD', cmd + ' unknown') def cmd_CAPABILITY(self, tag, args): - caps = ('IMAP4rev1 ' + self.capabilities - if self.capabilities - else 'IMAP4rev1') + caps = ' '.join(filter(None, (self.protocol_capabilities, self.capabilities))) self._send_textline('* CAPABILITY ' + caps) self._send_tagged(tag, 'OK', 'CAPABILITY completed') @@ -213,6 +213,11 @@ def cmd_IDLE(self, tag, args): self._send_tagged(tag, 'NO', 'IDLE is not allowed at this time') +class SimpleIMAPRev2Handler(SimpleIMAPHandler): + welcome = 'IMAP4rev2' + protocol_capabilities = 'IMAP4rev2' + + class IdleCmdHandler(SimpleIMAPHandler): capabilities = 'IDLE' def cmd_IDLE(self, tag, args): @@ -395,6 +400,53 @@ def cmd_APPEND(self, tag, args): '(~{25}', ('%s\r\n' % msg_string).encode('utf-8'), b')\r\n' ]) + def test_imap4rev2_enables_utf8_mode(self): + client, _ = self._setup(SimpleIMAPRev2Handler) + self.assertEqual(client.PROTOCOL_VERSION, 'IMAP4REV2') + self.assertTrue(client.utf8_enabled) + self.assertEqual(client._encoding, 'utf-8') + + + def test_imap4rev2_enable_works_without_ENABLE_capability(self): + class IMAP4Rev2EnableServer(SimpleIMAPRev2Handler): + capabilities = '' + def cmd_ENABLE(self, tag, args): + self.server.response = args + self._send_textline('* ENABLED ' + ' '.join(args)) + self._send_tagged(tag, 'OK', 'ENABLE successful') + client, server = self._setup(IMAP4Rev2EnableServer) + typ, _ = client.login('user', 'pass') + self.assertEqual(typ, 'OK') + typ, _ = client.enable('CONDSTORE') + self.assertEqual(typ, 'OK') + self.assertEqual(server.response, ['CONDSTORE']) + + def test_imap4rev2_idle_works_without_IDLE_capability(self): + class IMAP4Rev2IdleServer(SimpleIMAPRev2Handler): + capabilities = '' + def cmd_IDLE(self, tag, args): + self._send_textline('+ idling') + r = yield + if r == b'DONE\r\n': + self._send_tagged(tag, 'OK', 'IDLE completed') + else: + self._send_tagged(tag, 'BAD', 'Expected DONE') + client, _ = self._setup(IMAP4Rev2IdleServer) + typ, _ = client.login('user', 'pass') + self.assertEqual(typ, 'OK') + with client.idle(duration=0.01): + pass + + def test_dual_imap4rev1_imap4rev2_starts_in_rev1_mode(self): + class DualProtocolServer(SimpleIMAPHandler): + welcome = 'IMAP4rev2' + protocol_capabilities = 'IMAP4rev2 IMAP4rev1' + + client, _ = self._setup(DualProtocolServer) + self.assertEqual(client.PROTOCOL_VERSION, 'IMAP4REV1') + self.assertFalse(client.utf8_enabled) + self.assertEqual(client._encoding, 'ascii') + def test_search_disallows_charset_in_utf8_mode(self): class UTF8Server(SimpleIMAPHandler): capabilities = 'AUTH ENABLE UTF8=ACCEPT' diff --git a/Misc/NEWS.d/next/Library/2026-05-17-09-42-33.gh-issue-122953.94ShPG.rst b/Misc/NEWS.d/next/Library/2026-05-17-09-42-33.gh-issue-122953.94ShPG.rst new file mode 100644 index 00000000000000..621478c689aef4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-17-09-42-33.gh-issue-122953.94ShPG.rst @@ -0,0 +1,2 @@ +Update the :mod:`imaplib` module to support IMAP4rev2 following :rfc:`9501`, with backward compatibility with IMAP4rev1 +(outlined in :rfc:`9051#appendix-A`) and IMAP4rev1 extensions folded into IMAP4rev2 (outlined in :rfc:`9051#appendix-E`). \ No newline at end of file