Skip to content

Commit f6d7a81

Browse files
gh-49555: Support international mailbox names in imaplib
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 <noreply@anthropic.com>
1 parent 6d38668 commit f6d7a81

5 files changed

Lines changed: 126 additions & 37 deletions

File tree

Doc/library/imaplib.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,19 @@ In general, pass arguments unquoted and let the module quote them as needed.
192192
An argument that is already enclosed in double quotes is left unchanged,
193193
so that code which quotes arguments itself keeps working.
194194

195+
Mailbox names are encoded as modified UTF-7 (:rfc:`3501`, section 5.1.3),
196+
so a mailbox name containing non-ASCII characters can be passed as an
197+
ordinary :class:`str`.
198+
A :class:`str` that is already valid modified UTF-7 is left unchanged,
199+
so that a name obtained from :meth:`~IMAP4.list` (raw ``bytes`` decoded to
200+
text) round-trips; pass :class:`bytes` to send the exact bytes with no
201+
encoding.
202+
When ``UTF8=ACCEPT`` is enabled (see :meth:`~IMAP4.enable`), mailbox names
203+
are sent as UTF-8 instead.
204+
205+
.. versionchanged:: next
206+
Non-ASCII mailbox names are automatically encoded as modified UTF-7.
207+
195208
Most commands return a tuple: ``(type, [data, ...])`` where *type* is usually
196209
``'OK'`` or ``'NO'``, and *data* is either the text from the command response,
197210
or mandated results from the command. Each *data* is either a ``bytes``, or a

Doc/whatsnew/3.16.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,11 @@ imaplib
252252
user names and passwords.
253253
(Contributed by Przemysław Buczkowski and Serhiy Storchaka in :gh:`89869`.)
254254

255+
* Non-ASCII mailbox names are now automatically encoded as modified UTF-7
256+
(:rfc:`3501`, section 5.1.3), so international mailbox names can be passed
257+
as ordinary :class:`str`.
258+
(Contributed by Serhiy Storchaka in :gh:`49555`.)
259+
255260

256261
ipaddress
257262
---------

Lib/imaplib.py

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@ def append(self, mailbox, flags, date_time, message, *,
525525
if translate_line_endings:
526526
message = MapCRLF.sub(CRLF, message)
527527
self.literal = message
528-
return self._simple_command(name, self._astring(mailbox), flags, date_time)
528+
return self._simple_command(name, self._mailbox(mailbox), flags, date_time)
529529

530530

531531
def authenticate(self, mechanism, authobject):
@@ -596,30 +596,30 @@ def copy(self, message_set, new_mailbox):
596596
(typ, [data]) = <instance>.copy(message_set, new_mailbox)
597597
"""
598598
return self._simple_command('COPY', self._sequence_set(message_set),
599-
self._astring(new_mailbox))
599+
self._mailbox(new_mailbox))
600600

601601

602602
def create(self, mailbox):
603603
"""Create new mailbox.
604604
605605
(typ, [data]) = <instance>.create(mailbox)
606606
"""
607-
return self._simple_command('CREATE', self._astring(mailbox))
607+
return self._simple_command('CREATE', self._mailbox(mailbox))
608608

609609

610610
def delete(self, mailbox):
611611
"""Delete old mailbox.
612612
613613
(typ, [data]) = <instance>.delete(mailbox)
614614
"""
615-
return self._simple_command('DELETE', self._astring(mailbox))
615+
return self._simple_command('DELETE', self._mailbox(mailbox))
616616

617617
def deleteacl(self, mailbox, who):
618618
"""Delete the ACLs (remove any rights) set for who on mailbox.
619619
620620
(typ, [data]) = <instance>.deleteacl(mailbox, who)
621621
"""
622-
return self._simple_command('DELETEACL', self._astring(mailbox),
622+
return self._simple_command('DELETEACL', self._mailbox(mailbox),
623623
self._astring(who))
624624

625625
def enable(self, capability):
@@ -669,15 +669,15 @@ def getacl(self, mailbox):
669669
670670
(typ, [data]) = <instance>.getacl(mailbox)
671671
"""
672-
typ, dat = self._simple_command('GETACL', self._astring(mailbox))
672+
typ, dat = self._simple_command('GETACL', self._mailbox(mailbox))
673673
return self._untagged_response(typ, dat, 'ACL')
674674

675675

676676
def getannotation(self, mailbox, entry, attribute):
677677
"""(typ, [data]) = <instance>.getannotation(mailbox, entry, attribute)
678678
Retrieve ANNOTATIONs."""
679679

680-
typ, dat = self._simple_command('GETANNOTATION', self._astring(mailbox),
680+
typ, dat = self._simple_command('GETANNOTATION', self._mailbox(mailbox),
681681
entry, attribute)
682682
return self._untagged_response(typ, dat, 'ANNOTATION')
683683

@@ -689,7 +689,7 @@ def getquota(self, root):
689689
690690
(typ, [data]) = <instance>.getquota(root)
691691
"""
692-
typ, dat = self._simple_command('GETQUOTA', self._astring(root))
692+
typ, dat = self._simple_command('GETQUOTA', self._mailbox(root))
693693
return self._untagged_response(typ, dat, 'QUOTA')
694694

695695

@@ -698,7 +698,7 @@ def getquotaroot(self, mailbox):
698698
699699
(typ, [[QUOTAROOT responses...], [QUOTA responses]]) = <instance>.getquotaroot(mailbox)
700700
"""
701-
typ, dat = self._simple_command('GETQUOTAROOT', self._astring(mailbox))
701+
typ, dat = self._simple_command('GETQUOTAROOT', self._mailbox(mailbox))
702702
typ, quota = self._untagged_response(typ, dat, 'QUOTA')
703703
typ, quotaroot = self._untagged_response(typ, dat, 'QUOTAROOT')
704704
return typ, [quotaroot, quota]
@@ -747,7 +747,7 @@ def list(self, directory='', pattern='*'):
747747
'data' is list of LIST responses.
748748
"""
749749
name = 'LIST'
750-
typ, dat = self._simple_command(name, self._astring(directory),
750+
typ, dat = self._simple_command(name, self._mailbox(directory),
751751
self._list_mailbox(pattern))
752752
return self._untagged_response(typ, dat, name)
753753

@@ -838,7 +838,7 @@ def lsub(self, directory='', pattern='*'):
838838
'data' are tuples of message part envelope and data.
839839
"""
840840
name = 'LSUB'
841-
typ, dat = self._simple_command(name, self._astring(directory),
841+
typ, dat = self._simple_command(name, self._mailbox(directory),
842842
self._list_mailbox(pattern))
843843
return self._untagged_response(typ, dat, name)
844844

@@ -848,14 +848,14 @@ def move(self, message_set, new_mailbox):
848848
(typ, [data]) = <instance>.move(message_set, new_mailbox)
849849
"""
850850
return self._simple_command('MOVE', self._sequence_set(message_set),
851-
self._astring(new_mailbox))
851+
self._mailbox(new_mailbox))
852852

853853
def myrights(self, mailbox):
854854
"""Show my ACLs for a mailbox (i.e. the rights that I have on mailbox).
855855
856856
(typ, [data]) = <instance>.myrights(mailbox)
857857
"""
858-
typ,dat = self._simple_command('MYRIGHTS', self._astring(mailbox))
858+
typ,dat = self._simple_command('MYRIGHTS', self._mailbox(mailbox))
859859
return self._untagged_response(typ, dat, 'MYRIGHTS')
860860

861861
def namespace(self):
@@ -909,8 +909,8 @@ def rename(self, oldmailbox, newmailbox):
909909
910910
(typ, [data]) = <instance>.rename(oldmailbox, newmailbox)
911911
"""
912-
return self._simple_command('RENAME', self._astring(oldmailbox),
913-
self._astring(newmailbox))
912+
return self._simple_command('RENAME', self._mailbox(oldmailbox),
913+
self._mailbox(newmailbox))
914914

915915

916916
def search(self, charset, *criteria):
@@ -950,7 +950,7 @@ def select(self, mailbox='INBOX', readonly=False):
950950
name = 'EXAMINE'
951951
else:
952952
name = 'SELECT'
953-
typ, dat = self._simple_command(name, self._astring(mailbox))
953+
typ, dat = self._simple_command(name, self._mailbox(mailbox))
954954
if typ != 'OK':
955955
self.state = 'AUTH' # Might have been 'SELECTED'
956956
return typ, dat
@@ -969,15 +969,15 @@ def setacl(self, mailbox, who, what):
969969
970970
(typ, [data]) = <instance>.setacl(mailbox, who, what)
971971
"""
972-
return self._simple_command('SETACL', self._astring(mailbox),
972+
return self._simple_command('SETACL', self._mailbox(mailbox),
973973
self._astring(who), self._astring(what))
974974

975975

976976
def setannotation(self, mailbox, *args):
977977
"""(typ, [data]) = <instance>.setannotation(mailbox[, entry, attribute]+)
978978
Set ANNOTATIONs."""
979979

980-
typ, dat = self._simple_command('SETANNOTATION', self._astring(mailbox), *args)
980+
typ, dat = self._simple_command('SETANNOTATION', self._mailbox(mailbox), *args)
981981
return self._untagged_response(typ, dat, 'ANNOTATION')
982982

983983

@@ -986,7 +986,7 @@ def setquota(self, root, limits):
986986
987987
(typ, [data]) = <instance>.setquota(root, limits)
988988
"""
989-
typ, dat = self._simple_command('SETQUOTA', self._astring(root),
989+
typ, dat = self._simple_command('SETQUOTA', self._mailbox(root),
990990
self._set_quote(limits))
991991
return self._untagged_response(typ, dat, 'QUOTA')
992992

@@ -1038,7 +1038,7 @@ def status(self, mailbox, names):
10381038
name = 'STATUS'
10391039
#if self.PROTOCOL_VERSION == 'IMAP4': # Let the server decide!
10401040
# raise self.error('%s unimplemented in IMAP4 (obtain IMAP4rev1 server, or re-code)' % name)
1041-
typ, dat = self._simple_command(name, self._astring(mailbox),
1041+
typ, dat = self._simple_command(name, self._mailbox(mailbox),
10421042
self._set_quote(names))
10431043
return self._untagged_response(typ, dat, name)
10441044

@@ -1059,7 +1059,7 @@ def subscribe(self, mailbox):
10591059
10601060
(typ, [data]) = <instance>.subscribe(mailbox)
10611061
"""
1062-
return self._simple_command('SUBSCRIBE', self._astring(mailbox))
1062+
return self._simple_command('SUBSCRIBE', self._mailbox(mailbox))
10631063

10641064

10651065
def thread(self, threading_algorithm, charset, *search_criteria):
@@ -1095,7 +1095,7 @@ def uid(self, command, *args):
10951095
if command in ('COPY', 'MOVE'):
10961096
message_set, new_mailbox = args
10971097
args = (self._sequence_set(message_set),
1098-
self._astring(new_mailbox))
1098+
self._mailbox(new_mailbox))
10991099
elif command == 'FETCH':
11001100
message_set, message_parts = args
11011101
args = (self._sequence_set(message_set),
@@ -1129,7 +1129,7 @@ def unsubscribe(self, mailbox):
11291129
11301130
(typ, [data]) = <instance>.unsubscribe(mailbox)
11311131
"""
1132-
return self._simple_command('UNSUBSCRIBE', self._astring(mailbox))
1132+
return self._simple_command('UNSUBSCRIBE', self._mailbox(mailbox))
11331133

11341134

11351135
def unselect(self):
@@ -1545,9 +1545,36 @@ def _astring(self, arg):
15451545
return arg
15461546
return self._quote(arg)
15471547

1548+
def _encode_mailbox(self, arg, safe):
1549+
# Encode a mailbox name (or LIST pattern) for the wire. In the default
1550+
# (ASCII) mode a non-ASCII name uses modified UTF-7 (RFC 3501, 5.1.3).
1551+
# For backward compatibility a str that is already a valid modified
1552+
# UTF-7 token -- one that needs no quoting, or an explicitly quoted
1553+
# string -- is passed through unchanged, so that a name obtained as raw
1554+
# bytes from LIST (and decoded as ASCII) round-trips without being
1555+
# encoded again. Pass bytes to bypass encoding entirely.
1556+
if self._encoding != 'ascii':
1557+
return bytes(arg, self._encoding)
1558+
try:
1559+
raw = arg.encode('ascii')
1560+
except UnicodeEncodeError:
1561+
return arg.encode('utf-7-imap')
1562+
if _quoted.fullmatch(raw) or (raw and safe.search(raw) is None):
1563+
try:
1564+
raw.decode('utf-7-imap')
1565+
return raw
1566+
except UnicodeDecodeError:
1567+
pass
1568+
return arg.encode('utf-7-imap')
1569+
1570+
def _mailbox(self, arg):
1571+
if isinstance(arg, str):
1572+
arg = self._encode_mailbox(arg, _non_astring_char)
1573+
return self._astring(arg)
1574+
15481575
def _list_mailbox(self, arg):
15491576
if isinstance(arg, str):
1550-
arg = bytes(arg, self._encoding)
1577+
arg = self._encode_mailbox(arg, _non_list_char)
15511578
if _quoted.fullmatch(arg):
15521579
return arg
15531580
if arg and _non_list_char.search(arg) is None:

Lib/test/test_imaplib.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -229,12 +229,38 @@ def test_list_mailbox(self):
229229
# But spaces still require quoting.
230230
self.assertEqual(m._list_mailbox('New folder'), b'"New folder"')
231231
self.assertEqual(m._list_mailbox('"New folder"'), b'"New folder"')
232-
# As do non-ASCII names; wildcards keep their meaning inside a
233-
# quoted string.
232+
# A non-ASCII pattern is encoded as modified UTF-7; the wildcards keep
233+
# their meaning (they are printable ASCII, passed through directly).
234+
self.assertEqual(m._list_mailbox('Entwürfe/%'), b'Entw&APw-rfe/%')
235+
# Under UTF8=ACCEPT the name is sent as UTF-8 in a quoted string.
234236
m._encoding = 'utf-8'
235237
self.assertEqual(m._list_mailbox('Entwürfe/%'),
236238
'"Entwürfe/%"'.encode())
237239

240+
def test_mailbox(self):
241+
m = imaplib.IMAP4.__new__(imaplib.IMAP4)
242+
m._encoding = 'ascii'
243+
# A plain atom is left unquoted; bytes are sent verbatim.
244+
self.assertEqual(m._mailbox('INBOX'), b'INBOX')
245+
self.assertEqual(m._mailbox(b'Entw&APw-rfe'), b'Entw&APw-rfe')
246+
# A non-ASCII name is encoded as modified UTF-7 (RFC 3501 5.1.3).
247+
self.assertEqual(m._mailbox('Entwürfe'), b'Entw&APw-rfe')
248+
# A str that is already valid modified UTF-7 is passed through, so a
249+
# name obtained from LIST (raw bytes, decoded as ASCII) round-trips.
250+
self.assertEqual(m._mailbox('Entw&APw-rfe'), b'Entw&APw-rfe')
251+
self.assertEqual(m._mailbox('"Entw&APw-rfe"'), b'"Entw&APw-rfe"')
252+
# A literal '&' (not a valid shift on its own) is encoded as '&-'.
253+
self.assertEqual(m._mailbox('A&B'), b'A&-B')
254+
# Names needing quoting are still quoted.
255+
self.assertEqual(m._mailbox('New folder'), b'"New folder"')
256+
self.assertEqual(m._mailbox(''), b'""')
257+
# Control characters are Base64-encoded, never sent as raw bytes.
258+
self.assertEqual(m._mailbox('a\x01b'), b'a&AAE-b')
259+
# Under UTF8=ACCEPT a non-ASCII name is sent as UTF-8, quoted.
260+
m._encoding = 'utf-8'
261+
self.assertEqual(m._mailbox('Entwürfe'), '"Entwürfe"'.encode())
262+
self.assertEqual(m._mailbox('Entw&APw-rfe'), b'Entw&APw-rfe')
263+
238264

239265
if ssl:
240266
class SecureTCPServer(socketserver.TCPServer):
@@ -1883,18 +1909,32 @@ def test_uppercase_command_names(self):
18831909
def test_control_characters(self):
18841910
client, server = self._setup(SimpleIMAPHandler)
18851911
client.login('user', 'pass')
1886-
for c in '\0\r\n':
1887-
with self.assertRaises(ValueError):
1888-
client.select(f'a{c}b')
1889-
# Other control characters are valid in a quoted string and can
1890-
# occur in mailbox names returned by the server, so the client
1891-
# must be able to send them back.
1912+
# In the default (ASCII) mode a mailbox name is sent as modified UTF-7,
1913+
# which Base64-encodes every non-printable character (NUL, CR and LF
1914+
# included). So a control character is never sent as a raw byte -- it
1915+
# cannot corrupt the command -- and the name round-trips.
18921916
for c in support.control_characters_c0():
1893-
if c in '\0\r\n':
1894-
continue
1895-
typ, _ = client.select(f'a{c}b')
1896-
self.assertEqual(typ, 'OK')
1897-
self.assertEqual(server.is_selected, [f'"a{c}b"'])
1917+
with self.subTest(c=c):
1918+
typ, _ = client.select(f'a{c}b')
1919+
self.assertEqual(typ, 'OK')
1920+
[name] = server.is_selected
1921+
self.assertNotIn(c, name)
1922+
self.assertEqual(name.encode('ascii').decode('utf-7-imap'),
1923+
f'a{c}b')
1924+
1925+
def test_mutf7_mailbox_name(self):
1926+
# Without UTF8=ACCEPT a non-ASCII mailbox name is sent as modified
1927+
# UTF-7 (RFC 3501, 5.1.3). A name that is already modified UTF-7 (e.g.
1928+
# obtained from a raw LIST response) is sent unchanged, whether as str
1929+
# or bytes, so it round-trips without being encoded again.
1930+
client, server = self._setup(SimpleIMAPHandler)
1931+
client.login('user', 'pass')
1932+
for name in ['Entwürfe', 'Entw&APw-rfe', b'Entw&APw-rfe']:
1933+
with self.subTest(name=name):
1934+
server.is_selected = None
1935+
typ, _ = client.select(name)
1936+
self.assertEqual(typ, 'OK')
1937+
self.assertEqual(server.is_selected, ['Entw&APw-rfe'])
18981938

18991939
# property tests
19001940

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:mod:`imaplib` now encodes non-ASCII mailbox names as modified UTF-7
2+
(:rfc:`3501`, section 5.1.3), so international mailbox names can be passed as
3+
ordinary :class:`str`. A ``str`` that is already valid modified UTF-7, or a
4+
:class:`bytes` object, is sent unchanged.

0 commit comments

Comments
 (0)