From: Serhiy Storchaka Date: Thu, 9 Jul 2026 16:02:56 +0000 (+0300) Subject: gh-49555: Support international mailbox names in imaplib (GH-153391) X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=25bd6eb4372dba6ebb7b3f6ed2ace2395abf83b8;p=thirdparty%2FPython%2Fcpython.git gh-49555: Support international mailbox names in imaplib (GH-153391) Non-ASCII mailbox names are now encoded as modified UTF-7 (RFC 3501, section 5.1.3), so they can be passed as an ordinary str. Co-authored-by: Claude Opus 4.8 --- diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 53934dc698f1..6872ba0d6cbf 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -192,6 +192,19 @@ 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. +Mailbox names are encoded as modified UTF-7 (:rfc:`3501`, section 5.1.3), +so a mailbox name containing non-ASCII characters can be passed as an +ordinary :class:`str`. +A :class:`str` that is already valid modified UTF-7 is left unchanged, +so that a name obtained from :meth:`~IMAP4.list` (raw ``bytes`` decoded to +text) round-trips; pass :class:`bytes` to send the exact bytes with no +encoding. +When ``UTF8=ACCEPT`` is enabled (see :meth:`~IMAP4.enable`), mailbox names +are sent as UTF-8 instead. + +.. versionchanged:: next + Non-ASCII mailbox names are automatically encoded as modified UTF-7. + Most commands return a tuple: ``(type, [data, ...])`` where *type* is usually ``'OK'`` or ``'NO'``, and *data* is either the text from the command response, or mandated results from the command. Each *data* is either a ``bytes``, or a diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index e871ad822bbc..b8b3a5dd8948 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -252,6 +252,11 @@ imaplib user names and passwords. (Contributed by Przemysław Buczkowski and Serhiy Storchaka in :gh:`89869`.) +* Non-ASCII mailbox names are now automatically encoded as modified UTF-7 + (:rfc:`3501`, section 5.1.3), so international mailbox names can be passed + as ordinary :class:`str`. + (Contributed by Serhiy Storchaka in :gh:`49555`.) + ipaddress --------- diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 143c5856fb9b..d8acf085e7a1 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -525,7 +525,7 @@ class IMAP4: if translate_line_endings: message = MapCRLF.sub(CRLF, message) self.literal = message - return self._simple_command(name, self._astring(mailbox), flags, date_time) + return self._simple_command(name, self._mailbox(mailbox), flags, date_time) def authenticate(self, mechanism, authobject): @@ -596,7 +596,7 @@ class IMAP4: (typ, [data]) = .copy(message_set, new_mailbox) """ return self._simple_command('COPY', self._sequence_set(message_set), - self._astring(new_mailbox)) + self._mailbox(new_mailbox)) def create(self, mailbox): @@ -604,7 +604,7 @@ class IMAP4: (typ, [data]) = .create(mailbox) """ - return self._simple_command('CREATE', self._astring(mailbox)) + return self._simple_command('CREATE', self._mailbox(mailbox)) def delete(self, mailbox): @@ -612,14 +612,14 @@ class IMAP4: (typ, [data]) = .delete(mailbox) """ - return self._simple_command('DELETE', self._astring(mailbox)) + return self._simple_command('DELETE', self._mailbox(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', self._astring(mailbox), + return self._simple_command('DELETEACL', self._mailbox(mailbox), self._astring(who)) def enable(self, capability): @@ -669,7 +669,7 @@ class IMAP4: (typ, [data]) = .getacl(mailbox) """ - typ, dat = self._simple_command('GETACL', self._astring(mailbox)) + typ, dat = self._simple_command('GETACL', self._mailbox(mailbox)) return self._untagged_response(typ, dat, 'ACL') @@ -677,7 +677,7 @@ class IMAP4: """(typ, [data]) = .getannotation(mailbox, entry, attribute) Retrieve ANNOTATIONs.""" - typ, dat = self._simple_command('GETANNOTATION', self._astring(mailbox), + typ, dat = self._simple_command('GETANNOTATION', self._mailbox(mailbox), entry, attribute) return self._untagged_response(typ, dat, 'ANNOTATION') @@ -689,7 +689,7 @@ class IMAP4: (typ, [data]) = .getquota(root) """ - typ, dat = self._simple_command('GETQUOTA', self._astring(root)) + typ, dat = self._simple_command('GETQUOTA', self._mailbox(root)) return self._untagged_response(typ, dat, 'QUOTA') @@ -698,7 +698,7 @@ class IMAP4: (typ, [[QUOTAROOT responses...], [QUOTA responses]]) = .getquotaroot(mailbox) """ - typ, dat = self._simple_command('GETQUOTAROOT', self._astring(mailbox)) + typ, dat = self._simple_command('GETQUOTAROOT', self._mailbox(mailbox)) typ, quota = self._untagged_response(typ, dat, 'QUOTA') typ, quotaroot = self._untagged_response(typ, dat, 'QUOTAROOT') return typ, [quotaroot, quota] @@ -747,7 +747,7 @@ class IMAP4: 'data' is list of LIST responses. """ name = 'LIST' - typ, dat = self._simple_command(name, self._astring(directory), + typ, dat = self._simple_command(name, self._mailbox(directory), self._list_mailbox(pattern)) return self._untagged_response(typ, dat, name) @@ -838,7 +838,7 @@ class IMAP4: 'data' are tuples of message part envelope and data. """ name = 'LSUB' - typ, dat = self._simple_command(name, self._astring(directory), + typ, dat = self._simple_command(name, self._mailbox(directory), self._list_mailbox(pattern)) return self._untagged_response(typ, dat, name) @@ -848,14 +848,14 @@ class IMAP4: (typ, [data]) = .move(message_set, new_mailbox) """ return self._simple_command('MOVE', self._sequence_set(message_set), - self._astring(new_mailbox)) + self._mailbox(new_mailbox)) def myrights(self, mailbox): """Show my ACLs for a mailbox (i.e. the rights that I have on mailbox). (typ, [data]) = .myrights(mailbox) """ - typ,dat = self._simple_command('MYRIGHTS', self._astring(mailbox)) + typ,dat = self._simple_command('MYRIGHTS', self._mailbox(mailbox)) return self._untagged_response(typ, dat, 'MYRIGHTS') def namespace(self): @@ -909,8 +909,8 @@ class IMAP4: (typ, [data]) = .rename(oldmailbox, newmailbox) """ - return self._simple_command('RENAME', self._astring(oldmailbox), - self._astring(newmailbox)) + return self._simple_command('RENAME', self._mailbox(oldmailbox), + self._mailbox(newmailbox)) def search(self, charset, *criteria): @@ -950,7 +950,7 @@ class IMAP4: name = 'EXAMINE' else: name = 'SELECT' - typ, dat = self._simple_command(name, self._astring(mailbox)) + typ, dat = self._simple_command(name, self._mailbox(mailbox)) if typ != 'OK': self.state = 'AUTH' # Might have been 'SELECTED' return typ, dat @@ -969,7 +969,7 @@ class IMAP4: (typ, [data]) = .setacl(mailbox, who, what) """ - return self._simple_command('SETACL', self._astring(mailbox), + return self._simple_command('SETACL', self._mailbox(mailbox), self._astring(who), self._astring(what)) @@ -977,7 +977,7 @@ class IMAP4: """(typ, [data]) = .setannotation(mailbox[, entry, attribute]+) Set ANNOTATIONs.""" - typ, dat = self._simple_command('SETANNOTATION', self._astring(mailbox), *args) + typ, dat = self._simple_command('SETANNOTATION', self._mailbox(mailbox), *args) return self._untagged_response(typ, dat, 'ANNOTATION') @@ -986,7 +986,7 @@ class IMAP4: (typ, [data]) = .setquota(root, limits) """ - typ, dat = self._simple_command('SETQUOTA', self._astring(root), + typ, dat = self._simple_command('SETQUOTA', self._mailbox(root), self._set_quote(limits)) return self._untagged_response(typ, dat, 'QUOTA') @@ -1038,7 +1038,7 @@ 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, self._astring(mailbox), + typ, dat = self._simple_command(name, self._mailbox(mailbox), self._set_quote(names)) return self._untagged_response(typ, dat, name) @@ -1059,7 +1059,7 @@ class IMAP4: (typ, [data]) = .subscribe(mailbox) """ - return self._simple_command('SUBSCRIBE', self._astring(mailbox)) + return self._simple_command('SUBSCRIBE', self._mailbox(mailbox)) def thread(self, threading_algorithm, charset, *search_criteria): @@ -1095,7 +1095,7 @@ class IMAP4: if command in ('COPY', 'MOVE'): message_set, new_mailbox = args args = (self._sequence_set(message_set), - self._astring(new_mailbox)) + self._mailbox(new_mailbox)) elif command == 'FETCH': message_set, message_parts = args args = (self._sequence_set(message_set), @@ -1129,7 +1129,7 @@ class IMAP4: (typ, [data]) = .unsubscribe(mailbox) """ - return self._simple_command('UNSUBSCRIBE', self._astring(mailbox)) + return self._simple_command('UNSUBSCRIBE', self._mailbox(mailbox)) def unselect(self): @@ -1545,9 +1545,36 @@ class IMAP4: return arg return self._quote(arg) + def _encode_mailbox(self, arg, safe): + # Encode a mailbox name (or LIST pattern) for the wire. In the default + # (ASCII) mode a non-ASCII name uses modified UTF-7 (RFC 3501, 5.1.3). + # For backward compatibility a str that is already a valid modified + # UTF-7 token -- one that needs no quoting, or an explicitly quoted + # string -- is passed through unchanged, so that a name obtained as raw + # bytes from LIST (and decoded as ASCII) round-trips without being + # encoded again. Pass bytes to bypass encoding entirely. + if self._encoding != 'ascii': + return bytes(arg, self._encoding) + try: + raw = arg.encode('ascii') + except UnicodeEncodeError: + return arg.encode('utf-7-imap') + if _quoted.fullmatch(raw) or (raw and safe.search(raw) is None): + try: + raw.decode('utf-7-imap') + return raw + except UnicodeDecodeError: + pass + return arg.encode('utf-7-imap') + + def _mailbox(self, arg): + if isinstance(arg, str): + arg = self._encode_mailbox(arg, _non_astring_char) + return self._astring(arg) + def _list_mailbox(self, arg): if isinstance(arg, str): - arg = bytes(arg, self._encoding) + arg = self._encode_mailbox(arg, _non_list_char) if _quoted.fullmatch(arg): return arg if arg and _non_list_char.search(arg) is None: diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index e7ec0689236a..16a96381992b 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -229,12 +229,38 @@ class TestImaplib(unittest.TestCase): # 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. + # A non-ASCII pattern is encoded as modified UTF-7; the wildcards keep + # their meaning (they are printable ASCII, passed through directly). + self.assertEqual(m._list_mailbox('Entwürfe/%'), b'Entw&APw-rfe/%') + # Under UTF8=ACCEPT the name is sent as UTF-8 in a quoted string. m._encoding = 'utf-8' self.assertEqual(m._list_mailbox('Entwürfe/%'), '"Entwürfe/%"'.encode()) + def test_mailbox(self): + m = imaplib.IMAP4.__new__(imaplib.IMAP4) + m._encoding = 'ascii' + # A plain atom is left unquoted; bytes are sent verbatim. + self.assertEqual(m._mailbox('INBOX'), b'INBOX') + self.assertEqual(m._mailbox(b'Entw&APw-rfe'), b'Entw&APw-rfe') + # A non-ASCII name is encoded as modified UTF-7 (RFC 3501 5.1.3). + self.assertEqual(m._mailbox('Entwürfe'), b'Entw&APw-rfe') + # A str that is already valid modified UTF-7 is passed through, so a + # name obtained from LIST (raw bytes, decoded as ASCII) round-trips. + self.assertEqual(m._mailbox('Entw&APw-rfe'), b'Entw&APw-rfe') + self.assertEqual(m._mailbox('"Entw&APw-rfe"'), b'"Entw&APw-rfe"') + # A literal '&' (not a valid shift on its own) is encoded as '&-'. + self.assertEqual(m._mailbox('A&B'), b'A&-B') + # Names needing quoting are still quoted. + self.assertEqual(m._mailbox('New folder'), b'"New folder"') + self.assertEqual(m._mailbox(''), b'""') + # Control characters are Base64-encoded, never sent as raw bytes. + self.assertEqual(m._mailbox('a\x01b'), b'a&AAE-b') + # Under UTF8=ACCEPT a non-ASCII name is sent as UTF-8, quoted. + m._encoding = 'utf-8' + self.assertEqual(m._mailbox('Entwürfe'), '"Entwürfe"'.encode()) + self.assertEqual(m._mailbox('Entw&APw-rfe'), b'Entw&APw-rfe') + if ssl: class SecureTCPServer(socketserver.TCPServer): @@ -1909,18 +1935,32 @@ class NewIMAPTestsMixin: def test_control_characters(self): client, server = self._setup(SimpleIMAPHandler) client.login('user', 'pass') - for c in '\0\r\n': - with self.assertRaises(ValueError): - client.select(f'a{c}b') - # Other control characters are valid in a quoted string and can - # occur in mailbox names returned by the server, so the client - # must be able to send them back. + # In the default (ASCII) mode a mailbox name is sent as modified UTF-7, + # which Base64-encodes every non-printable character (NUL, CR and LF + # included). So a control character is never sent as a raw byte -- it + # cannot corrupt the command -- and the name round-trips. for c in support.control_characters_c0(): - if c in '\0\r\n': - continue - typ, _ = client.select(f'a{c}b') - self.assertEqual(typ, 'OK') - self.assertEqual(server.is_selected, [f'"a{c}b"']) + with self.subTest(c=c): + typ, _ = client.select(f'a{c}b') + self.assertEqual(typ, 'OK') + [name] = server.is_selected + self.assertNotIn(c, name) + self.assertEqual(name.encode('ascii').decode('utf-7-imap'), + f'a{c}b') + + def test_mutf7_mailbox_name(self): + # Without UTF8=ACCEPT a non-ASCII mailbox name is sent as modified + # UTF-7 (RFC 3501, 5.1.3). A name that is already modified UTF-7 (e.g. + # obtained from a raw LIST response) is sent unchanged, whether as str + # or bytes, so it round-trips without being encoded again. + client, server = self._setup(SimpleIMAPHandler) + client.login('user', 'pass') + for name in ['Entwürfe', 'Entw&APw-rfe', b'Entw&APw-rfe']: + with self.subTest(name=name): + server.is_selected = None + typ, _ = client.select(name) + self.assertEqual(typ, 'OK') + self.assertEqual(server.is_selected, ['Entw&APw-rfe']) # property tests diff --git a/Misc/NEWS.d/next/Library/2026-07-09-12-00-00.gh-issue-49555.Ka9Lm2.rst b/Misc/NEWS.d/next/Library/2026-07-09-12-00-00.gh-issue-49555.Ka9Lm2.rst new file mode 100644 index 000000000000..87236e98a079 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-09-12-00-00.gh-issue-49555.Ka9Lm2.rst @@ -0,0 +1,4 @@ +:mod:`imaplib` now encodes non-ASCII mailbox names as modified UTF-7 +(:rfc:`3501`, section 5.1.3), so international mailbox names can be passed as +ordinary :class:`str`. A ``str`` that is already valid modified UTF-7, or a +:class:`bytes` object, is sent unchanged.