Skip to content

Commit e9adcbc

Browse files
committed
gh-122953: add support for IMAP4rev2 in imaplib
1 parent a7d5a6c commit e9adcbc

4 files changed

Lines changed: 141 additions & 26 deletions

File tree

Doc/library/imaplib.rst

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515

1616
This module defines three classes, :class:`IMAP4`, :class:`IMAP4_SSL` and
1717
:class:`IMAP4_stream`, which encapsulate a connection to an IMAP4 server and
18-
implement a large subset of the IMAP4rev1 client protocol as defined in
19-
:rfc:`2060`. It is backward compatible with IMAP4 (:rfc:`1730`) servers, but
20-
note that the ``STATUS`` command is not supported in IMAP4.
18+
implement IMAP4rev1 and IMAP4rev2 client protocol features. It is backward
19+
compatible with IMAP4 (:rfc:`1730`) servers, but note that the ``STATUS``
20+
command is not supported in IMAP4.
2121

2222
.. include:: ../includes/wasm-notavail.rst
2323

@@ -28,7 +28,7 @@ base class:
2828
.. class:: IMAP4(host='', port=IMAP4_PORT, timeout=None)
2929

3030
This class implements the actual IMAP4 protocol. The connection is created and
31-
protocol version (IMAP4 or IMAP4rev1) is determined when the instance is
31+
protocol version (IMAP4, IMAP4rev1, or IMAP4rev2) is determined when the instance is
3232
initialized. If *host* is not specified, ``''`` (the local host) is used. If
3333
*port* is omitted, the standard IMAP4 port (143) is used. The optional *timeout*
3434
parameter specifies a timeout in seconds for the connection attempt.
@@ -169,8 +169,8 @@ example of usage.
169169
IMAP4 Objects
170170
-------------
171171

172-
All IMAP4rev1 commands are represented by methods of the same name, either
173-
uppercase or lowercase.
172+
All IMAP4rev2 and IMAP4rev1 commands are represented by methods of the
173+
same name, either uppercase or lowercase.
174174

175175
All arguments to commands are converted to strings, except for ``AUTHENTICATE``,
176176
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:
260260
.. method:: IMAP4.enable(capability)
261261

262262
Enable *capability* (see :rfc:`5161`). Most capabilities do not need to be
263-
enabled. Currently only the ``UTF8=ACCEPT`` capability is supported
264-
(see :RFC:`6855`).
263+
enabled. ``UTF8=ACCEPT`` has special client-side handling
264+
(see :RFC:`6855`). On servers that advertise both ``IMAP4rev1`` and
265+
``IMAP4rev2``, ``ENABLE IMAP4REV2`` switches the connection from its
266+
initial IMAP4rev1-compatible mode into IMAP4rev2 mode.
265267

266268
.. versionadded:: 3.5
267269
The :meth:`enable` method itself, and :RFC:`6855` support.
@@ -507,7 +509,9 @@ An :class:`IMAP4` instance has the following methods:
507509
protocol requires that at least one criterion be specified; an exception will be
508510
raised when the server returns an error. *charset* must be ``None`` if
509511
the ``UTF8=ACCEPT`` capability was enabled using the :meth:`enable`
510-
command.
512+
command; :rfc:`6855` requires this once UTF-8 support has been enabled.
513+
In IMAP4rev2 mode (:rfc:`9051`), UTF-8 is already in use, so specifying an explicit
514+
``CHARSET`` such as ``UTF-8`` is redundant, which implies that *charset* need not be ``None``.
511515

512516
Example::
513517

@@ -524,6 +528,11 @@ An :class:`IMAP4` instance has the following methods:
524528
(``EXISTS`` response). The default *mailbox* is ``'INBOX'``. If the *readonly*
525529
flag is set, modifications to the mailbox are not allowed.
526530

531+
With the :rfc:`6855` ``UTF8=ACCEPT`` extension, the server can open
532+
mailboxes containing internationalized messages with ``SELECT`` and
533+
``EXAMINE``. In IMAP4rev2 mode, UTF-8 support is part of the protocol
534+
instead of being enabled separately.
535+
527536

528537
.. method:: IMAP4.send(data)
529538

@@ -681,8 +690,9 @@ The following attributes are defined on instances of :class:`IMAP4`:
681690

682691
.. attribute:: IMAP4.PROTOCOL_VERSION
683692

684-
The most recent supported protocol in the ``CAPABILITY`` response from the
685-
server.
693+
The protocol version currently in use for the connection. If the server
694+
advertises both ``IMAP4rev1`` and ``IMAP4rev2``, the connection starts in
695+
``IMAP4rev1`` mode until ``ENABLE IMAP4REV2`` is issued successfully.
686696

687697

688698
.. attribute:: IMAP4.debug
@@ -695,7 +705,7 @@ The following attributes are defined on instances of :class:`IMAP4`:
695705

696706
Boolean value that is normally ``False``, but is set to ``True`` if an
697707
:meth:`enable` command is successfully issued for the ``UTF8=ACCEPT``
698-
capability.
708+
capability, and also when the connection enters IMAP4rev2 mode.
699709

700710
.. versionadded:: 3.5
701711

Lib/imaplib.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""IMAP4 client.
22
3-
Based on RFC 2060.
3+
Based on RFC 2060 and updated for RFC 9051.
44
55
Public class: IMAP4
66
Public variable: Debug
@@ -40,7 +40,11 @@
4040
Debug = 0
4141
IMAP4_PORT = 143
4242
IMAP4_SSL_PORT = 993
43-
AllowedVersions = ('IMAP4REV1', 'IMAP4') # Most recent first
43+
AllowedVersions = ('IMAP4REV2', 'IMAP4REV1', 'IMAP4') # Most recent first
44+
IMAP4REV2_BUILTIN_COMMANDS = (
45+
'NAMESPACE', 'UNSELECT', 'UIDPLUS', 'ESEARCH', 'SEARCHRES',
46+
'ENABLE', 'IDLE', 'SASL-IR', 'LIST-EXTENDED', 'LIST-STATUS',
47+
'MOVE', 'LITERAL+') # RFC 9051 Appendix E.2
4448

4549
# Maximal line length when calling readline(). This is to prevent
4650
# reading arbitrary length lines. RFC 3501 and 2060 (IMAP 4rev1)
@@ -144,7 +148,7 @@ class IMAP4:
144148
If timeout is not given or is None,
145149
the global default socket timeout is used
146150
147-
All IMAP4rev1 commands are supported by methods of the same
151+
All IMAP4rev2 and IMAP4rev1 commands are supported by methods of the same
148152
name (in lowercase).
149153
150154
All arguments to commands are converted to strings, except for
@@ -198,6 +202,7 @@ def __init__(self, host='', port=IMAP4_PORT, timeout=None):
198202
self.is_readonly = False # READ-ONLY desired state
199203
self.tagnum = 0
200204
self._tls_established = False
205+
self.PROTOCOL_VERSION = None
201206
self._mode_ascii()
202207
self._readbuf = []
203208

@@ -260,14 +265,59 @@ def _connect(self):
260265
if self.debug >= 3:
261266
self._mesg('CAPABILITIES: %r' % (self.capabilities,))
262267

268+
self._set_protocol_version()
269+
270+
271+
def _set_protocol_version(self):
272+
273+
if self._should_use_rev1_mode_for_dual_support():
274+
self._activate_rev1_mode()
275+
return
276+
263277
for version in AllowedVersions:
264278
if not version in self.capabilities:
265279
continue
266-
self.PROTOCOL_VERSION = version
280+
if version == 'IMAP4REV2':
281+
self._activate_rev2_mode()
282+
else:
283+
self.PROTOCOL_VERSION = version
267284
return
268285

269286
raise self.error('server not IMAP4 compliant')
270287

288+
def _supports_rev2(self):
289+
return 'IMAP4REV2' in self.capabilities
290+
291+
def _supports_dual_rev1_rev2(self):
292+
return self._supports_rev2() and 'IMAP4REV1' in self.capabilities
293+
294+
def _is_using_rev2(self):
295+
return self.PROTOCOL_VERSION == 'IMAP4REV2'
296+
297+
def _is_capability_available(self, capability):
298+
capability = capability.upper()
299+
if self._is_using_rev2() and capability in IMAP4REV2_BUILTIN_COMMANDS:
300+
return True
301+
return capability in self.capabilities
302+
303+
def _should_use_rev1_mode_for_dual_support(self):
304+
return self._supports_dual_rev1_rev2()
305+
306+
def _activate_rev1_mode(self):
307+
self.PROTOCOL_VERSION = 'IMAP4REV1'
308+
self._mode_ascii()
309+
310+
def _activate_rev2_mode(self):
311+
self.PROTOCOL_VERSION = 'IMAP4REV2'
312+
self._mode_utf8()
313+
314+
def _handle_enable_success(self, capability):
315+
capability = capability.upper()
316+
if 'UTF8=ACCEPT' in capability:
317+
self._mode_utf8()
318+
if 'IMAP4REV2' in capability:
319+
self._activate_rev2_mode()
320+
271321

272322
def __getattr__(self, attr):
273323
# Allow UPPERCASE variants of IMAP4 command methods.
@@ -603,11 +653,11 @@ def enable(self, capability):
603653
604654
(typ, [data]) = <instance>.enable(capability)
605655
"""
606-
if 'ENABLE' not in self.capabilities:
656+
if not self._is_capability_available('ENABLE'):
607657
raise IMAP4.error("Server does not support ENABLE")
608658
typ, data = self._simple_command('ENABLE', capability)
609-
if typ == 'OK' and 'UTF8=ACCEPT' in capability.upper():
610-
self._mode_utf8()
659+
if typ == 'OK':
660+
self._handle_enable_success(capability)
611661
return typ, data
612662

613663
def expunge(self):
@@ -838,7 +888,7 @@ def search(self, charset, *criteria):
838888
"""
839889
name = 'SEARCH'
840890
if charset:
841-
if self.utf8_enabled:
891+
if self.utf8_enabled and not self._is_using_rev2():
842892
raise IMAP4.error("Non-None charset not valid in UTF8 mode")
843893
typ, dat = self._simple_command(name, 'CHARSET', charset, *criteria)
844894
else:
@@ -936,6 +986,7 @@ def starttls(self, ssl_context=None):
936986
self._imaplib_file = self.sock.makefile('rb')
937987
self._tls_established = True
938988
self._get_capabilities()
989+
self._set_protocol_version()
939990
else:
940991
raise self.error("Couldn't establish TLS session")
941992
return self._untagged_response(typ, dat, name)
@@ -1439,7 +1490,7 @@ class Idler:
14391490
"""
14401491

14411492
def __init__(self, imap, duration=None):
1442-
if 'IDLE' not in imap.capabilities:
1493+
if not imap._is_capability_available('IDLE'):
14431494
raise imap.error("Server does not support IMAP4 IDLE")
14441495
if duration is not None and not imap.sock:
14451496
# IMAP4_stream pipes don't support timeouts

Lib/test/test_imaplib.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ class SimpleIMAPHandler(socketserver.StreamRequestHandler):
116116
timeout = support.LOOPBACK_TIMEOUT
117117
continuation = None
118118
capabilities = ''
119+
welcome = 'IMAP4rev1'
120+
protocol_capabilities = 'IMAP4rev1'
119121

120122
def setup(self):
121123
super().setup()
@@ -136,9 +138,9 @@ def _send_textline(self, message):
136138
def _send_tagged(self, tag, code, message):
137139
self._send_textline(' '.join((tag, code, message)))
138140

139-
def handle(self):
141+
def handle(self):
140142
# Send a welcome message.
141-
self._send_textline('* OK IMAP4rev1')
143+
self._send_textline(f'* OK {self.welcome}')
142144
while 1:
143145
# Gather up input until we receive a line terminator or we timeout.
144146
# Accumulate read(1) because it's simpler to handle the differences
@@ -179,9 +181,7 @@ def handle(self):
179181
self._send_tagged(tag, 'BAD', cmd + ' unknown')
180182

181183
def cmd_CAPABILITY(self, tag, args):
182-
caps = ('IMAP4rev1 ' + self.capabilities
183-
if self.capabilities
184-
else 'IMAP4rev1')
184+
caps = ' '.join(filter(None, (self.protocol_capabilities, self.capabilities)))
185185
self._send_textline('* CAPABILITY ' + caps)
186186
self._send_tagged(tag, 'OK', 'CAPABILITY completed')
187187

@@ -213,6 +213,11 @@ def cmd_IDLE(self, tag, args):
213213
self._send_tagged(tag, 'NO', 'IDLE is not allowed at this time')
214214

215215

216+
class SimpleIMAPRev2Handler(SimpleIMAPHandler):
217+
welcome = 'IMAP4rev2'
218+
protocol_capabilities = 'IMAP4rev2'
219+
220+
216221
class IdleCmdHandler(SimpleIMAPHandler):
217222
capabilities = 'IDLE'
218223
def cmd_IDLE(self, tag, args):
@@ -395,6 +400,53 @@ def cmd_APPEND(self, tag, args):
395400
'(~{25}', ('%s\r\n' % msg_string).encode('utf-8'),
396401
b')\r\n' ])
397402

403+
def test_imap4rev2_enables_utf8_mode(self):
404+
client, _ = self._setup(SimpleIMAPRev2Handler)
405+
self.assertEqual(client.PROTOCOL_VERSION, 'IMAP4REV2')
406+
self.assertTrue(client.utf8_enabled)
407+
self.assertEqual(client._encoding, 'utf-8')
408+
409+
410+
def test_imap4rev2_enable_works_without_ENABLE_capability(self):
411+
class IMAP4Rev2EnableServer(SimpleIMAPRev2Handler):
412+
capabilities = ''
413+
def cmd_ENABLE(self, tag, args):
414+
self.server.response = args
415+
self._send_textline('* ENABLED ' + ' '.join(args))
416+
self._send_tagged(tag, 'OK', 'ENABLE successful')
417+
client, server = self._setup(IMAP4Rev2EnableServer)
418+
typ, _ = client.login('user', 'pass')
419+
self.assertEqual(typ, 'OK')
420+
typ, _ = client.enable('CONDSTORE')
421+
self.assertEqual(typ, 'OK')
422+
self.assertEqual(server.response, ['CONDSTORE'])
423+
424+
def test_imap4rev2_idle_works_without_IDLE_capability(self):
425+
class IMAP4Rev2IdleServer(SimpleIMAPRev2Handler):
426+
capabilities = ''
427+
def cmd_IDLE(self, tag, args):
428+
self._send_textline('+ idling')
429+
r = yield
430+
if r == b'DONE\r\n':
431+
self._send_tagged(tag, 'OK', 'IDLE completed')
432+
else:
433+
self._send_tagged(tag, 'BAD', 'Expected DONE')
434+
client, _ = self._setup(IMAP4Rev2IdleServer)
435+
typ, _ = client.login('user', 'pass')
436+
self.assertEqual(typ, 'OK')
437+
with client.idle(duration=0.01):
438+
pass
439+
440+
def test_dual_imap4rev1_imap4rev2_starts_in_rev1_mode(self):
441+
class DualProtocolServer(SimpleIMAPHandler):
442+
welcome = 'IMAP4rev2'
443+
protocol_capabilities = 'IMAP4rev2 IMAP4rev1'
444+
445+
client, _ = self._setup(DualProtocolServer)
446+
self.assertEqual(client.PROTOCOL_VERSION, 'IMAP4REV1')
447+
self.assertFalse(client.utf8_enabled)
448+
self.assertEqual(client._encoding, 'ascii')
449+
398450
def test_search_disallows_charset_in_utf8_mode(self):
399451
class UTF8Server(SimpleIMAPHandler):
400452
capabilities = 'AUTH ENABLE UTF8=ACCEPT'
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Update the :mod:`imaplib` module to support IMAP4rev2 following :rfc:`9501`, with backward compatibility with IMAP4rev1
2+
(outlined in :rfc:`9051#appendix-A`) and IMAP4rev1 extensions folded into IMAP4rev2 (outlined in :rfc:`9051#appendix-E`).

0 commit comments

Comments
 (0)