From: Serhiy Storchaka Date: Sun, 5 Jul 2026 06:12:58 +0000 (+0300) Subject: gh-40038: Quote imaplib command arguments when necessary (GH-152703) X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=600e86490fddf4732e8b993058ca011cc6387464;p=thirdparty%2FPython%2Fcpython.git gh-40038: Quote imaplib command arguments when necessary (GH-152703) Argument quoting was inadvertently disabled when imaplib was ported to Python 3 (bpo-1210 commented out the ``_checkquote()`` call, bpo-9638 then removed it), so since Python 3.0 commands failed for arguments containing protocol-sensitive characters, such as a space in a mailbox name. Quoting is restored and reimplemented per the RFC 3501 grammar, so that arguments that need quoting are escaped and quoted, while flags, sequence sets and list wildcards are left intact. For backward compatibility, an argument already enclosed in double quotes is left unchanged, so code that quotes arguments itself keeps working. Co-Authored-By: Claude Opus 4.8 (1M context) --- diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index db17f6b79a7c..dbf4560ab5e5 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -186,6 +186,9 @@ enclosed with either parentheses or double quotes) each string is quoted. However, the *password* argument to the ``LOGIN`` command is always quoted. If you want to avoid having an argument string quoted (eg: the *flags* argument to ``STORE``) then enclose the string in parentheses (eg: ``r'(\Deleted)'``). +In general, pass arguments unquoted and let the module quote them as needed. +An argument that is already enclosed in double quotes is left unchanged, +so that code which quotes arguments itself keeps working. Most commands return a tuple: ``(type, [data, ...])`` where *type* is usually ``'OK'`` or ``'NO'``, and *data* is either the text from the command response, @@ -410,7 +413,7 @@ An :class:`IMAP4` instance has the following methods: .. versionadded:: 3.14 -.. method:: IMAP4.list([directory[, pattern]]) +.. method:: IMAP4.list(directory='', pattern='*') List mailbox names in *directory* matching *pattern*. *directory* defaults to the top-level mail folder, and *pattern* defaults to match anything. Returned @@ -440,7 +443,7 @@ An :class:`IMAP4` instance has the following methods: The method no longer ignores silently arbitrary exceptions. -.. method:: IMAP4.lsub(directory='""', pattern='*') +.. method:: IMAP4.lsub(directory='', pattern='*') List subscribed mailbox names in directory matching pattern. *directory* defaults to the top level directory and *pattern* defaults to match any mailbox. diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 70abfa741444..a871e509b0d8 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -130,6 +130,9 @@ Untagged_status = re.compile( _Literal = br'.*{(?P\d+)}$' _Untagged_status = br'\* (?P\d+) (?P[A-Z-]+)( (?P.*))?' _control_chars = re.compile(b'[\x00-\x1F\x7F]') +_non_astring_char = re.compile(br'[(){ \x00-\x1f\x7f-\xff%*\\"]') +_non_list_char = re.compile(br'[(){ \x00-\x1f\x7f-\xff\\"]') +_quoted = re.compile(br'"(?:[^"\\]|\\.)*+"') class IMAP4: @@ -503,8 +506,7 @@ class IMAP4: if not mailbox: mailbox = 'INBOX' if flags: - if (flags[0],flags[-1]) != ('(',')'): - flags = '(%s)' % flags + flags = self._set_quote(flags) else: flags = None if date_time: @@ -514,7 +516,7 @@ class IMAP4: if translate_line_endings: message = MapCRLF.sub(CRLF, message) self.literal = message - return self._simple_command(name, mailbox, flags, date_time) + return self._simple_command(name, self._astring(mailbox), flags, date_time) def authenticate(self, mechanism, authobject): @@ -539,7 +541,7 @@ class IMAP4: #if not cap in self.capabilities: # Let the server decide! # raise self.error("Server doesn't allow %s authentication." % mech) self.literal = _Authenticator(authobject).process - typ, dat = self._simple_command('AUTHENTICATE', mech) + typ, dat = self._simple_command('AUTHENTICATE', self._atom(mech)) if typ != 'OK': raise self.error(dat[-1].decode('utf-8', 'replace')) self.state = 'AUTH' @@ -584,7 +586,8 @@ class IMAP4: (typ, [data]) = .copy(message_set, new_mailbox) """ - return self._simple_command('COPY', message_set, new_mailbox) + return self._simple_command('COPY', self._sequence_set(message_set), + self._astring(new_mailbox)) def create(self, mailbox): @@ -592,7 +595,7 @@ class IMAP4: (typ, [data]) = .create(mailbox) """ - return self._simple_command('CREATE', mailbox) + return self._simple_command('CREATE', self._astring(mailbox)) def delete(self, mailbox): @@ -600,14 +603,15 @@ class IMAP4: (typ, [data]) = .delete(mailbox) """ - return self._simple_command('DELETE', mailbox) + return self._simple_command('DELETE', self._astring(mailbox)) def deleteacl(self, mailbox, who): """Delete the ACLs (remove any rights) set for who on mailbox. (typ, [data]) = .deleteacl(mailbox, who) """ - return self._simple_command('DELETEACL', mailbox, who) + return self._simple_command('DELETEACL', self._astring(mailbox), + self._astring(who)) def enable(self, capability): """Send an RFC5161 enable string to the server. @@ -646,7 +650,8 @@ class IMAP4: 'data' are tuples of message part envelope and data. """ name = 'FETCH' - typ, dat = self._simple_command(name, message_set, message_parts) + typ, dat = self._simple_command(name, self._sequence_set(message_set), + self._fetch_parts(message_parts)) return self._untagged_response(typ, dat, name) @@ -655,7 +660,7 @@ class IMAP4: (typ, [data]) = .getacl(mailbox) """ - typ, dat = self._simple_command('GETACL', mailbox) + typ, dat = self._simple_command('GETACL', self._astring(mailbox)) return self._untagged_response(typ, dat, 'ACL') @@ -663,7 +668,8 @@ class IMAP4: """(typ, [data]) = .getannotation(mailbox, entry, attribute) Retrieve ANNOTATIONs.""" - typ, dat = self._simple_command('GETANNOTATION', mailbox, entry, attribute) + typ, dat = self._simple_command('GETANNOTATION', self._astring(mailbox), + entry, attribute) return self._untagged_response(typ, dat, 'ANNOTATION') @@ -674,7 +680,7 @@ class IMAP4: (typ, [data]) = .getquota(root) """ - typ, dat = self._simple_command('GETQUOTA', root) + typ, dat = self._simple_command('GETQUOTA', self._astring(root)) return self._untagged_response(typ, dat, 'QUOTA') @@ -683,7 +689,7 @@ class IMAP4: (typ, [[QUOTAROOT responses...], [QUOTA responses]]) = .getquotaroot(mailbox) """ - typ, dat = self._simple_command('GETQUOTAROOT', mailbox) + typ, dat = self._simple_command('GETQUOTAROOT', self._astring(mailbox)) typ, quota = self._untagged_response(typ, dat, 'QUOTA') typ, quotaroot = self._untagged_response(typ, dat, 'QUOTAROOT') return typ, [quotaroot, quota] @@ -702,15 +708,16 @@ class IMAP4: return Idler(self, duration) - def list(self, directory='""', pattern='*'): + def list(self, directory='', pattern='*'): """List mailbox names in directory matching pattern. - (typ, [data]) = .list(directory='""', pattern='*') + (typ, [data]) = .list(directory='', pattern='*') 'data' is list of LIST responses. """ name = 'LIST' - typ, dat = self._simple_command(name, directory, pattern) + typ, dat = self._simple_command(name, self._astring(directory), + self._list_mailbox(pattern)) return self._untagged_response(typ, dat, name) @@ -721,7 +728,8 @@ class IMAP4: NB: 'password' will be quoted. """ - typ, dat = self._simple_command('LOGIN', user, self._quote(password)) + typ, dat = self._simple_command('LOGIN', self._astring(user), + self._quote(password)) if typ != 'OK': raise self.error(dat[-1].decode('UTF-8', 'replace')) self.state = 'AUTH' @@ -767,15 +775,16 @@ class IMAP4: return typ, dat - def lsub(self, directory='""', pattern='*'): + def lsub(self, directory='', pattern='*'): """List 'subscribed' mailbox names in directory matching pattern. - (typ, [data, ...]) = .lsub(directory='""', pattern='*') + (typ, [data, ...]) = .lsub(directory='', pattern='*') 'data' are tuples of message part envelope and data. """ name = 'LSUB' - typ, dat = self._simple_command(name, directory, pattern) + typ, dat = self._simple_command(name, self._astring(directory), + self._list_mailbox(pattern)) return self._untagged_response(typ, dat, name) def myrights(self, mailbox): @@ -783,7 +792,7 @@ class IMAP4: (typ, [data]) = .myrights(mailbox) """ - typ,dat = self._simple_command('MYRIGHTS', mailbox) + typ,dat = self._simple_command('MYRIGHTS', self._astring(mailbox)) return self._untagged_response(typ, dat, 'MYRIGHTS') def namespace(self): @@ -829,7 +838,7 @@ class IMAP4: """ name = 'PROXYAUTH' - return self._simple_command(name, user) + return self._simple_command(name, self._astring(user)) def rename(self, oldmailbox, newmailbox): @@ -837,7 +846,8 @@ class IMAP4: (typ, [data]) = .rename(oldmailbox, newmailbox) """ - return self._simple_command('RENAME', oldmailbox, newmailbox) + return self._simple_command('RENAME', self._astring(oldmailbox), + self._astring(newmailbox)) def search(self, charset, *criteria): @@ -849,10 +859,11 @@ class IMAP4: If UTF8 is enabled, charset MUST be None. """ name = 'SEARCH' - if charset: + if charset is not None: if self.utf8_enabled: raise IMAP4.error("Non-None charset not valid in UTF8 mode") - typ, dat = self._simple_command(name, 'CHARSET', charset, *criteria) + typ, dat = self._simple_command(name, + 'CHARSET', self._astring(charset), *criteria) else: typ, dat = self._simple_command(name, *criteria) return self._untagged_response(typ, dat, name) @@ -876,7 +887,7 @@ class IMAP4: name = 'EXAMINE' else: name = 'SELECT' - typ, dat = self._simple_command(name, mailbox) + typ, dat = self._simple_command(name, self._astring(mailbox)) if typ != 'OK': self.state = 'AUTH' # Might have been 'SELECTED' return typ, dat @@ -895,14 +906,15 @@ class IMAP4: (typ, [data]) = .setacl(mailbox, who, what) """ - return self._simple_command('SETACL', mailbox, who, what) + return self._simple_command('SETACL', self._astring(mailbox), + self._astring(who), self._astring(what)) - def setannotation(self, *args): + def setannotation(self, mailbox, *args): """(typ, [data]) = .setannotation(mailbox[, entry, attribute]+) Set ANNOTATIONs.""" - typ, dat = self._simple_command('SETANNOTATION', *args) + typ, dat = self._simple_command('SETANNOTATION', self._astring(mailbox), *args) return self._untagged_response(typ, dat, 'ANNOTATION') @@ -911,7 +923,8 @@ class IMAP4: (typ, [data]) = .setquota(root, limits) """ - typ, dat = self._simple_command('SETQUOTA', root, limits) + typ, dat = self._simple_command('SETQUOTA', self._astring(root), + self._set_quote(limits)) return self._untagged_response(typ, dat, 'QUOTA') @@ -923,8 +936,9 @@ class IMAP4: name = 'SORT' #if not name in self.capabilities: # Let the server decide! # raise self.error('unimplemented extension command: %s' % name) - if (sort_criteria[0],sort_criteria[-1]) != ('(',')'): - sort_criteria = '(%s)' % sort_criteria + sort_criteria = self._set_quote(sort_criteria) + if charset is not None: + charset = self._astring(charset) typ, dat = self._simple_command(name, sort_criteria, charset, *search_criteria) return self._untagged_response(typ, dat, name) @@ -961,7 +975,8 @@ class IMAP4: name = 'STATUS' #if self.PROTOCOL_VERSION == 'IMAP4': # Let the server decide! # raise self.error('%s unimplemented in IMAP4 (obtain IMAP4rev1 server, or re-code)' % name) - typ, dat = self._simple_command(name, mailbox, names) + typ, dat = self._simple_command(name, self._astring(mailbox), + self._set_quote(names)) return self._untagged_response(typ, dat, name) @@ -970,9 +985,9 @@ class IMAP4: (typ, [data]) = .store(message_set, command, flags) """ - if (flags[0],flags[-1]) != ('(',')'): - flags = '(%s)' % flags # Avoid quoting the flags - typ, dat = self._simple_command('STORE', message_set, command, flags) + flags = self._set_quote(flags) + typ, dat = self._simple_command('STORE', self._sequence_set(message_set), + command, flags) return self._untagged_response(typ, dat, 'FETCH') @@ -981,7 +996,7 @@ class IMAP4: (typ, [data]) = .subscribe(mailbox) """ - return self._simple_command('SUBSCRIBE', mailbox) + return self._simple_command('SUBSCRIBE', self._astring(mailbox)) def thread(self, threading_algorithm, charset, *search_criteria): @@ -990,7 +1005,10 @@ class IMAP4: (type, [data]) = .thread(threading_algorithm, charset, search_criteria, ...) """ name = 'THREAD' - typ, dat = self._simple_command(name, threading_algorithm, charset, *search_criteria) + if charset is not None: + charset = self._astring(charset) + typ, dat = self._simple_command(name, self._atom(threading_algorithm), + charset, *search_criteria) return self._untagged_response(typ, dat, name) @@ -1011,7 +1029,31 @@ class IMAP4: (command, self.state, ', '.join(Commands[command]))) name = 'UID' - typ, dat = self._simple_command(name, command, *args) + if command == 'COPY': + message_set, new_mailbox = args + args = (self._sequence_set(message_set), + self._astring(new_mailbox)) + elif command == 'FETCH': + message_set, message_parts = args + args = (self._sequence_set(message_set), + self._fetch_parts(message_parts)) + elif command == 'STORE': + message_set, op, flags = args + args = (self._sequence_set(message_set), op, + self._set_quote(flags)) + elif command == 'SORT': + sort_criteria, charset, *search_criteria = args + if charset is not None: + charset = self._astring(charset) + args = (self._set_quote(sort_criteria), charset, + *search_criteria) + elif command == 'THREAD': + threading_algorithm, charset, *search_criteria = args + if charset is not None: + charset = self._astring(charset) + args = (self._atom(threading_algorithm), charset, + *search_criteria) + typ, dat = self._simple_command(name, self._atom(command), *args) if command in ('SEARCH', 'SORT', 'THREAD'): name = command else: @@ -1024,7 +1066,7 @@ class IMAP4: (typ, [data]) = .unsubscribe(mailbox) """ - return self._simple_command('UNSUBSCRIBE', mailbox) + return self._simple_command('UNSUBSCRIBE', self._astring(mailbox)) def unselect(self): @@ -1393,13 +1435,53 @@ class IMAP4: return tag - def _quote(self, arg): + def _atom(self, arg): + return arg + + def _sequence_set(self, arg): + return arg - arg = arg.replace('\\', '\\\\') - arg = arg.replace('"', '\\"') + def _set_quote(self, arg): + if arg and arg[0] == '(' and arg[-1] == ')': + return arg + return '(' + arg + ')' - return '"' + arg + '"' + def _fetch_parts(self, arg): + # "ALL", "FULL" and "FAST" are macros, not data item names; + # they cannot be enclosed in parentheses. + if arg.upper() in ('ALL', 'FULL', 'FAST'): + return arg + return self._set_quote(arg) + def _quote(self, arg): + if isinstance(arg, str): + arg = bytes(arg, self._encoding) + arg = arg.replace(b'\\', br'\\') + arg = arg.replace(b'"', br'\"') + return b'"' + arg + b'"' + + # For backward compatibility, an argument already enclosed in double + # quotes is left unquoted, so that code which quotes arguments itself + # keeps working. New code should pass arguments unquoted and let the + # module quote them as needed. + + def _astring(self, arg): + if isinstance(arg, str): + arg = bytes(arg, self._encoding) + if _quoted.fullmatch(arg): + return arg + if arg and _non_astring_char.search(arg) is None: + return arg + return self._quote(arg) + + def _list_mailbox(self, arg): + if isinstance(arg, str): + arg = bytes(arg, self._encoding) + if _quoted.fullmatch(arg): + return arg + if arg and _non_list_char.search(arg) is None: + return arg + return self._quote(arg) def _simple_command(self, name, *args): diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 097056c91a7f..5d9ca170ea59 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -176,6 +176,65 @@ class TestImaplib(unittest.TestCase): imaplib.IMAP4() self.assertIn(cm.exception.errno, expected_errnos) + def test_astring(self): + m = imaplib.IMAP4.__new__(imaplib.IMAP4) + m._encoding = 'ascii' + # Plain atoms are left unquoted. + self.assertEqual(m._astring('INBOX'), b'INBOX') + self.assertEqual(m._astring(b'INBOX'), b'INBOX') + # Names with protocol-sensitive characters are quoted. + self.assertEqual(m._astring('New folder'), b'"New folder"') + self.assertEqual(m._astring('a"b'), b'"a\\"b"') + self.assertEqual(m._astring('a\\b'), b'"a\\\\b"') + self.assertEqual(m._astring(''), b'""') + self.assertEqual(m._astring('*'), b'"*"') + # A well-formed quoted string is passed through unchanged. + self.assertEqual(m._astring('"New folder"'), b'"New folder"') + self.assertEqual(m._astring('""'), b'""') + # Including a lenient (non-RFC) backslash escape, which the server + # may accept. + self.assertEqual(m._astring('"a\\b"'), b'"a\\b"') + # A string that only looks quoted but is not a single token is + # quoted as data, closing the argument injection vector. + self.assertEqual(m._astring('"a" SELECT evil "'), + b'"\\"a\\" SELECT evil \\""') + self.assertEqual(m._astring('"'), b'"\\""') + # Non-ASCII names are only allowed in a quoted string or a + # literal, never in an atom (RFC 6855). + m._encoding = 'utf-8' + self.assertEqual(m._astring('Entwürfe'), '"Entwürfe"'.encode()) + self.assertEqual(m._astring(b'Entw\xc3\xbcrfe'), b'"Entw\xc3\xbcrfe"') + + def test_astring_idempotent(self): + # Quoting an already quoted argument should not change it, so that + # quoting twice gives the same result as quoting once. + m = imaplib.IMAP4.__new__(imaplib.IMAP4) + m._encoding = 'ascii' + for arg in ['INBOX', 'New folder', 'a"b', 'a\\b', '', '*', '%', + '"New folder"', '""', '"a\\b"', '"a" SELECT evil "', + '"', 'a\tb', 'a\rb', '\x7f', '(a)', b'Entw\xc3\xbcrfe']: + with self.subTest(arg=arg): + once = m._astring(arg) + self.assertEqual(m._astring(once), once) + twice = m._list_mailbox(arg) + self.assertEqual(m._list_mailbox(twice), twice) + + def test_list_mailbox(self): + m = imaplib.IMAP4.__new__(imaplib.IMAP4) + m._encoding = 'ascii' + # Wildcards are not quoted in a list pattern. + self.assertEqual(m._list_mailbox('*'), b'*') + self.assertEqual(m._list_mailbox('%'), b'%') + self.assertEqual(m._list_mailbox('foo/%'), b'foo/%') + # But spaces still require quoting. + self.assertEqual(m._list_mailbox('New folder'), b'"New folder"') + self.assertEqual(m._list_mailbox('"New folder"'), b'"New folder"') + # As do non-ASCII names; wildcards keep their meaning inside a + # quoted string. + m._encoding = 'utf-8' + self.assertEqual(m._list_mailbox('Entwürfe/%'), + '"Entwürfe/%"'.encode()) + if ssl: class SecureTCPServer(socketserver.TCPServer): @@ -279,7 +338,7 @@ class SimpleIMAPHandler(socketserver.StreamRequestHandler): self._send_tagged(tag, 'OK', 'LOGOUT completed') def cmd_LOGIN(self, tag, args): - self.server.logged = args[0] + self.server.logged = args self._send_tagged(tag, 'OK', 'LOGIN completed') def cmd_SELECT(self, tag, args): @@ -510,6 +569,7 @@ class NewIMAPTestsMixin: code, _ = client.enable('UTF8=ACCEPT') self.assertEqual(code, 'OK') self.assertEqual(client._encoding, 'utf-8') + self.assertEqual(server.args, ['UTF8=ACCEPT']) msg_string = 'Subject: üñí©öðé' typ, data = client.append( None, None, None, (msg_string + '\n').encode('utf-8')) @@ -537,6 +597,26 @@ class NewIMAPTestsMixin: with self.assertRaisesRegex(imaplib.IMAP4.error, 'charset.*UTF8'): client.search('foo', 'bar') + def test_utf8_mailbox_name(self): + class UTF8Server(SimpleIMAPHandler): + capabilities = 'AUTH ENABLE UTF8=ACCEPT' + def cmd_ENABLE(self, tag, args): + self._send_tagged(tag, 'OK', 'ENABLE successful') + def cmd_AUTHENTICATE(self, tag, args): + self._send_textline('+') + self.server.response = yield + self._send_tagged(tag, 'OK', 'FAKEAUTH successful') + client, server = self._setup(UTF8Server) + typ, _ = client.authenticate('MYAUTH', lambda x: b'fake') + self.assertEqual(typ, 'OK') + typ, _ = client.enable('UTF8=ACCEPT') + self.assertEqual(typ, 'OK') + # A non-ASCII mailbox name is only allowed in a quoted string + # or a literal, never in an atom (RFC 6855). + typ, _ = client.select('Entwürfe') + self.assertEqual(typ, 'OK') + self.assertEqual(server.is_selected, ['"Entwürfe"']) + def test_bad_auth_name(self): class MyServer(SimpleIMAPHandler): def cmd_AUTHENTICATE(self, tag, args): @@ -669,7 +749,7 @@ class NewIMAPTestsMixin: _, server = self._setup(SimpleIMAPHandler, connect=False) with self.imap_class(*server.server_address) as imap: imap.login('user', 'pass') - self.assertEqual(server.logged, 'user') + self.assertEqual(server.logged, ['user', '"pass"']) self.assertIsNone(server.logged) def test_with_statement_logout(self): @@ -677,7 +757,7 @@ class NewIMAPTestsMixin: _, server = self._setup(SimpleIMAPHandler, connect=False) with self.imap_class(*server.server_address) as imap: imap.login('user', 'pass') - self.assertEqual(server.logged, 'user') + self.assertEqual(server.logged, ['user', '"pass"']) imap.logout() self.assertIsNone(server.logged) self.assertIsNone(server.logged) @@ -752,11 +832,33 @@ class NewIMAPTestsMixin: self.fail('multi-packet response was corrupted by idle timeout') def test_login(self): - client, _ = self._setup(SimpleIMAPHandler) + client, server = self._setup(SimpleIMAPHandler) typ, data = client.login('user', 'pass') self.assertEqual(typ, 'OK') self.assertEqual(data[0], b'LOGIN completed') self.assertEqual(client.state, 'AUTH') + # The user name is quoted only when necessary, but the password + # is always quoted. + self.assertEqual(server.logged, ['user', '"pass"']) + self.assertRaises(imaplib.IMAP4.error, client.login, 'user', 'pass') + + def test_login_quoted(self): + client, server = self._setup(SimpleIMAPHandler) + typ, data = client.login('us*r', 'p%ss') + self.assertEqual(typ, 'OK') + self.assertEqual(data[0], b'LOGIN completed') + self.assertEqual(client.state, 'AUTH') + self.assertEqual(server.logged, ['"us*r"', '"p%ss"']) + + def test_login_quoted2(self): + # An already quoted user name is passed through unchanged, rather + # than being quoted a second time; the password is always quoted. + client, server = self._setup(SimpleIMAPHandler) + typ, data = client.login('"user"', '"pass"') + self.assertEqual(typ, 'OK') + self.assertEqual(data[0], b'LOGIN completed') + self.assertEqual(client.state, 'AUTH') + self.assertEqual(server.logged, ['"user"', r'"\"pass\""']) def test_append_translate_line_endings(self): # By default line endings are normalized to CRLF; False sends the @@ -785,6 +887,11 @@ class NewIMAPTestsMixin: translate_line_endings=False) self.assertEqual(server.response, message) + # The mailbox is quoted and the flags are wrapped in parentheses + # when necessary. + client.append('New folder', r'\Seen', None, b'data') + self.assertEqual(server.args, ['"New folder"', r'(\Seen)', '{4}']) + def test_login_capabilities(self): # A server may advertise new capabilities after login (as an # untagged CAPABILITY response); imaplib must refresh its cached @@ -873,6 +980,12 @@ class NewIMAPTestsMixin: self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['~/Mail/', '%']) + # The directory is quoted when necessary; wildcards in the pattern + # are preserved. + typ, data = client.lsub('New folder', '%') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"', '%']) + def test_extra_blank_line_after_literal(self): # Some buggy servers send an extra blank line after the counted # literal data. imaplib should skip it instead of failing. @@ -940,6 +1053,10 @@ class NewIMAPTestsMixin: self.assertEqual(server.is_selected, ['INBOX']) self.assertTrue(client.is_readonly) + typ, data = client.select('New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(server.is_selected, ['"New folder"']) + def test_expunge(self): client, server = self._setup(make_simple_handler('EXPUNGE', ['* 3 EXPUNGE', '* 3 EXPUNGE', '* 5 EXPUNGE', '* 8 EXPUNGE'])) @@ -1022,6 +1139,11 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'CREATE completed']) self.assertEqual(server.args, ['owatagusiam/blurdybloop']) + typ, data = client.create('New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'CREATE completed']) + self.assertEqual(server.args, ['"New folder"']) + def test_copy(self): client, server = self._setup(make_simple_handler('COPY')) client.login('user', 'pass') @@ -1031,6 +1153,11 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'COPY completed']) self.assertEqual(server.args, ['2:4', 'MEETING']) + typ, data = client.copy('2:4', 'New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'COPY completed']) + self.assertEqual(server.args, ['2:4', '"New folder"']) + def test_uid_copy(self): client, server = self._setup(make_simple_handler('UID', completed='UID COPY completed')) @@ -1041,6 +1168,11 @@ class NewIMAPTestsMixin: self.assertEqual(data, [None]) self.assertEqual(server.args, ['COPY', '4827313:4828442', 'MEETING']) + typ, data = client.uid('copy', '4827313:4828442', 'New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(data, [None]) + self.assertEqual(server.args, ['COPY', '4827313:4828442', '"New folder"']) + def test_store(self): client, server = self._setup(make_simple_handler('STORE', [ r'* 2 FETCH (FLAGS (\Deleted \Seen))', @@ -1079,6 +1211,10 @@ class NewIMAPTestsMixin: ]) self.assertEqual(server.args, ['STORE', '4827313:4828442', '+FLAGS', r'(\Deleted)']) + typ, data = client.uid('store', '4827313:4828442', '+FLAGS', r'\Deleted') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['STORE', '4827313:4828442', '+FLAGS', r'(\Deleted)']) + def test_fetch(self): # The handler expands the requested sequence set and answers for # exactly those messages, so the test exercises the round trip of @@ -1123,6 +1259,19 @@ class NewIMAPTestsMixin: self.assertEqual(data, [br'1 (FLAGS (\Seen))']) self.assertEqual(server.args, ['1', '(BODY[HEADER.FIELDS (DATE FROM)])']) + # message_parts is wrapped in parentheses if it is not already. + typ, data = client.fetch('2:4', 'FLAGS') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2:4', '(FLAGS)']) + + # But the macros are not, as they are not data item names. + typ, data = client.fetch('2:4', 'ALL') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2:4', 'ALL']) + typ, data = client.fetch('2:4', 'fast') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2:4', 'fast']) + def test_uid_fetch(self): client, server = self._setup(make_simple_handler('UID', [ r'* 23 FETCH (FLAGS (\Seen) UID 4827313)', @@ -1140,6 +1289,14 @@ class NewIMAPTestsMixin: ]) self.assertEqual(server.args, ['FETCH', '4827313:4828442', '(FLAGS)']) + typ, data = client.uid('fetch', '4827313:4828442', 'FLAGS') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['FETCH', '4827313:4828442', '(FLAGS)']) + + typ, data = client.uid('fetch', '4827313:4828442', 'ALL') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['FETCH', '4827313:4828442', 'ALL']) + def test_partial(self): client, server = self._setup(make_simple_handler('PARTIAL', ['* 1 FETCH (RFC822.TEXT<0.10> "0123456789")'])) @@ -1173,6 +1330,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'43']) self.assertEqual(server.args, ['CHARSET', 'UTF-8', 'TEXT', 'XXXXXX']) + typ, data = client.search('NF_Z_62-010_(1973)', 'TEXT', 'XXXXXX') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['CHARSET', '"NF_Z_62-010_(1973)"', 'TEXT', 'XXXXXX']) + def test_uid_search(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1224,6 +1385,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [br'']) self.assertEqual(server.args, ['(SUBJECT)', 'US-ASCII', 'TEXT', '"not in mailbox"']) + typ, data = client.sort('SUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"not in mailbox"') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['(SUBJECT)', '"NF_Z_62-010_(1973)"', 'TEXT', '"not in mailbox"']) + def test_uid_sort(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1248,6 +1413,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [br'']) self.assertEqual(server.args, ['SORT', '(SUBJECT)', 'US-ASCII', 'TEXT', '"not in mailbox"']) + typ, data = client.uid('sort', 'SUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"not in mailbox"') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['SORT', '(SUBJECT)', '"NF_Z_62-010_(1973)"', 'TEXT', '"not in mailbox"']) + def test_thread(self): response = [] client, server = self._setup(make_simple_handler('THREAD', response)) @@ -1285,6 +1454,10 @@ class NewIMAPTestsMixin: b'(199)(200 202)(201)(203)(204)(205 206 207)(208)']) self.assertEqual(server.args, ['ORDEREDSUBJECT', 'US-ASCII', 'TEXT', '"gewp"']) + typ, data = client.thread('ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"gewp"') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['ORDEREDSUBJECT', '"NF_Z_62-010_(1973)"', 'TEXT', '"gewp"']) + def test_uid_thread(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1323,6 +1496,10 @@ class NewIMAPTestsMixin: b'(199)(200 202)(201)(203)(204)(205 206 207)(208)']) self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', 'US-ASCII', 'TEXT', '"gewp"']) + typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"gewp"') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', '"NF_Z_62-010_(1973)"', 'TEXT', '"gewp"']) + def test_delete(self): client, server = self._setup(make_simple_handler('DELETE')) client.login('user', 'pass') @@ -1335,6 +1512,10 @@ class NewIMAPTestsMixin: self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['foo/bar']) + typ, data = client.delete('New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"']) + def test_rename(self): client, server = self._setup(make_simple_handler('RENAME')) client.login('user', 'pass') @@ -1343,6 +1524,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'RENAME completed']) self.assertEqual(server.args, ['blurdybloop', 'sarasoop']) + typ, data = client.rename('Old folder', 'New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"Old folder"', '"New folder"']) + def test_subscribe(self): client, server = self._setup(make_simple_handler('SUBSCRIBE')) client.login('user', 'pass') @@ -1351,6 +1536,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'SUBSCRIBE completed']) self.assertEqual(server.args, ['#news.comp.mail.mime']) + typ, data = client.subscribe('New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"']) + def test_unsubscribe(self): client, server = self._setup(make_simple_handler('UNSUBSCRIBE')) client.login('user', 'pass') @@ -1359,6 +1548,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'UNSUBSCRIBE completed']) self.assertEqual(server.args, ['#news.comp.mail.mime']) + typ, data = client.unsubscribe('New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"']) + def test_list(self): client, server = self._setup(make_simple_handler('LIST', [r'* LIST (\Noselect) "/" ""', @@ -1374,6 +1567,15 @@ class NewIMAPTestsMixin: self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['~/Mail/', '%']) + typ, data = client.list('New folder', '*') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"', '*']) + + # A pattern without wildcards is quoted when necessary. + typ, data = client.list('~/Mail/', 'My Folder') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['~/Mail/', '"My Folder"']) + def test_status(self): client, server = self._setup(make_simple_handler('STATUS', ['* STATUS blurdybloop (MESSAGES 231 UIDNEXT 44292)'])) @@ -1383,6 +1585,11 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'blurdybloop (MESSAGES 231 UIDNEXT 44292)']) self.assertEqual(server.args, ['blurdybloop', '(UIDNEXT MESSAGES)']) + # The names argument is wrapped in parentheses if it is not already. + typ, data = client.status('New folder', 'UIDNEXT MESSAGES') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"', '(UIDNEXT MESSAGES)']) + def test_getacl(self): client, server = self._setup(make_simple_handler('GETACL', ['* ACL INBOX Fred rwipslxetad'])) @@ -1392,6 +1599,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'INBOX Fred rwipslxetad']) self.assertEqual(server.args, ['INBOX']) + typ, data = client.getacl('New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"']) + def test_setacl(self): client, server = self._setup(make_simple_handler('SETACL')) client.login('user', 'pass') @@ -1400,6 +1611,15 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'SETACL completed']) self.assertEqual(server.args, ['INBOX', 'Fred', 'rwipslxetad']) + typ, data = client.setacl('New folder', 'Fred', '+lr') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"', 'Fred', '+lr']) + + # The identifier and the rights are quoted when necessary too. + typ, data = client.setacl('INBOX', 'John Doe', 'a b') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['INBOX', '"John Doe"', '"a b"']) + def test_deleteacl(self): client, server = self._setup(make_simple_handler('DELETEACL')) client.login('user', 'pass') @@ -1408,6 +1628,11 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'DELETEACL completed']) self.assertEqual(server.args, ['INBOX', 'Fred']) + # The identifier is quoted when necessary too. + typ, data = client.deleteacl('New folder', 'John Doe') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"', '"John Doe"']) + def test_myrights(self): client, server = self._setup(make_simple_handler('MYRIGHTS', ['* MYRIGHTS INBOX rwiptsldaex'])) @@ -1417,6 +1642,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'INBOX rwiptsldaex']) self.assertEqual(server.args, ['INBOX']) + typ, data = client.myrights('New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"']) + def test_getquota(self): client, server = self._setup(make_simple_handler('GETQUOTA', ['* QUOTA "" (STORAGE 10 512)'])) @@ -1426,6 +1655,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'"" (STORAGE 10 512)']) self.assertEqual(server.args, ['#news']) + typ, data = client.getquota('') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['""']) + def test_getquotaroot(self): client, server = self._setup(make_simple_handler('GETQUOTAROOT', ['* QUOTAROOT INBOX ""', '* QUOTA "" (STORAGE 10 512)'])) @@ -1435,6 +1668,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [[b'INBOX ""'], [b'"" (STORAGE 10 512)']]) self.assertEqual(server.args, ['INBOX']) + typ, data = client.getquotaroot('New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"']) + def test_setquota(self): client, server = self._setup(make_simple_handler('SETQUOTA', ['* QUOTA "" (STORAGE 512)'])) @@ -1444,6 +1681,15 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'"" (STORAGE 512)']) self.assertEqual(server.args, ['#news', '(STORAGE 512)']) + typ, data = client.setquota('', '(STORAGE 512)') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['""', '(STORAGE 512)']) + + # The limits argument is wrapped in parentheses if it is not already. + typ, data = client.setquota('', 'STORAGE 512') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['""', '(STORAGE 512)']) + def test_getannotation(self): client, server = self._setup(make_simple_handler('GETANNOTATION', ['* ANNOTATION INBOX "/comment" ("value.shared" "Hello")'])) @@ -1453,6 +1699,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'INBOX "/comment" ("value.shared" "Hello")']) self.assertEqual(server.args, ['INBOX', '/comment', 'value.shared']) + typ, data = client.getannotation('New folder', '/comment', 'value.shared') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"New folder"', '/comment', 'value.shared']) + def test_setannotation(self): client, server = self._setup(make_simple_handler('SETANNOTATION')) client.login('user', 'pass') @@ -1462,6 +1712,12 @@ class NewIMAPTestsMixin: self.assertEqual(server.args, ['INBOX', '/comment', '("value.shared" "My comment")']) + typ, data = client.setannotation('New folder', '/comment', + '("value.shared" "My comment")') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['"New folder"', '/comment', '("value.shared" "My comment")']) + def test_proxyauth(self): client, server = self._setup(make_simple_handler('PROXYAUTH')) client.login('user', 'pass') @@ -1470,6 +1726,10 @@ class NewIMAPTestsMixin: self.assertEqual(data, [b'PROXYAUTH completed']) self.assertEqual(server.args, ['user']) + typ, data = client.proxyauth('us er') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['"us er"']) + def test_xatom(self): client, server = self._setup(make_simple_handler('MYCOMMAND', completed='MYCOMMAND completed')) @@ -1753,6 +2013,7 @@ class ThreadedNetworkedTests(unittest.TestCase): code, _ = client.enable('UTF8=ACCEPT') self.assertEqual(code, 'OK') self.assertEqual(client._encoding, 'utf-8') + self.assertEqual(server.args, ['UTF8=ACCEPT']) msg_string = 'Subject: üñí©öðé' typ, data = client.append( None, None, None, (msg_string + '\n').encode('utf-8')) @@ -1908,7 +2169,7 @@ class ThreadedNetworkedTests(unittest.TestCase): with self.reaped_server(SimpleIMAPHandler) as server: with self.imap_class(*server.server_address) as imap: imap.login('user', 'pass') - self.assertEqual(server.logged, 'user') + self.assertEqual(server.logged, ['user', '"pass"']) self.assertIsNone(server.logged) @threading_helper.reap_threads @@ -1917,7 +2178,7 @@ class ThreadedNetworkedTests(unittest.TestCase): with self.reaped_server(SimpleIMAPHandler) as server: with self.imap_class(*server.server_address) as imap: imap.login('user', 'pass') - self.assertEqual(server.logged, 'user') + self.assertEqual(server.logged, ['user', '"pass"']) imap.logout() self.assertIsNone(server.logged) self.assertIsNone(server.logged) diff --git a/Misc/NEWS.d/next/Library/2026-06-30-12-00-00.gh-issue-40038.qK7mGv.rst b/Misc/NEWS.d/next/Library/2026-06-30-12-00-00.gh-issue-40038.qK7mGv.rst new file mode 100644 index 000000000000..1f393d23266b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-30-12-00-00.gh-issue-40038.qK7mGv.rst @@ -0,0 +1,6 @@ +:mod:`imaplib` now again quotes command arguments when necessary, for +example mailbox names containing a space. Such quoting was inadvertently +disabled when the module was ported to Python 3, and the arguments are now +quoted according to the :rfc:`3501` grammar. For backward compatibility, +an argument already enclosed in double quotes is left unchanged, so code +that quotes arguments itself keeps working.