Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Doc/library/imaplib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------
Expand Down
75 changes: 51 additions & 24 deletions Lib/imaplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -596,30 +596,30 @@ def copy(self, message_set, new_mailbox):
(typ, [data]) = <instance>.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):
"""Create new mailbox.

(typ, [data]) = <instance>.create(mailbox)
"""
return self._simple_command('CREATE', self._astring(mailbox))
return self._simple_command('CREATE', self._mailbox(mailbox))


def delete(self, mailbox):
"""Delete old mailbox.

(typ, [data]) = <instance>.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]) = <instance>.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):
Expand Down Expand Up @@ -669,15 +669,15 @@ def getacl(self, mailbox):

(typ, [data]) = <instance>.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')


def getannotation(self, mailbox, entry, attribute):
"""(typ, [data]) = <instance>.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')

Expand All @@ -689,7 +689,7 @@ def getquota(self, root):

(typ, [data]) = <instance>.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')


Expand All @@ -698,7 +698,7 @@ def getquotaroot(self, mailbox):

(typ, [[QUOTAROOT responses...], [QUOTA responses]]) = <instance>.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]
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -848,14 +848,14 @@ def move(self, message_set, new_mailbox):
(typ, [data]) = <instance>.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]) = <instance>.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):
Expand Down Expand Up @@ -909,8 +909,8 @@ def rename(self, oldmailbox, newmailbox):

(typ, [data]) = <instance>.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):
Expand Down Expand Up @@ -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
Expand All @@ -969,15 +969,15 @@ def setacl(self, mailbox, who, what):

(typ, [data]) = <instance>.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))


def setannotation(self, mailbox, *args):
"""(typ, [data]) = <instance>.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')


Expand All @@ -986,7 +986,7 @@ def setquota(self, root, limits):

(typ, [data]) = <instance>.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')

Expand Down Expand Up @@ -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)

Expand All @@ -1059,7 +1059,7 @@ def subscribe(self, mailbox):

(typ, [data]) = <instance>.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):
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1129,7 +1129,7 @@ def unsubscribe(self, mailbox):

(typ, [data]) = <instance>.unsubscribe(mailbox)
"""
return self._simple_command('UNSUBSCRIBE', self._astring(mailbox))
return self._simple_command('UNSUBSCRIBE', self._mailbox(mailbox))


def unselect(self):
Expand Down Expand Up @@ -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:
Expand Down
66 changes: 53 additions & 13 deletions Lib/test/test_imaplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading