diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 53934dc698f19e..6872ba0d6cbfcb 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 e871ad822bbced..b8b3a5dd894872 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 7b472c302fb061..06451fffef94c6 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -525,7 +525,7 @@ def append(self, mailbox, flags, date_time, message, *, 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 @@ def copy(self, message_set, new_mailbox): (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 @@ def create(self, mailbox): (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 @@ def delete(self, mailbox): (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 @@ def getacl(self, mailbox): (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 @@ def getannotation(self, mailbox, entry, attribute): """(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 @@ def getquota(self, root): (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 @@ def getquotaroot(self, mailbox): (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 @@ def list(self, directory='', pattern='*'): '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 @@ def lsub(self, directory='', pattern='*'): '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 @@ def move(self, message_set, new_mailbox): (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 @@ def rename(self, oldmailbox, newmailbox): (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 @@ def select(self, mailbox='INBOX', readonly=False): 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 @@ def setacl(self, mailbox, who, what): (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 @@ def setannotation(self, mailbox, *args): """(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 @@ def setquota(self, root, limits): (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 @@ def status(self, mailbox, names): 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 @@ def subscribe(self, mailbox): (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 @@ def uid(self, command, *args): 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 @@ def unsubscribe(self, mailbox): (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 @@ def _astring(self, arg): 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 30297fb5afcd19..de044a00e45f44 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -229,12 +229,38 @@ def test_list_mailbox(self): # 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): @@ -1883,18 +1909,32 @@ def test_uppercase_command_names(self): 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 00000000000000..87236e98a07901 --- /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.