Skip to content

Commit 94a8424

Browse files
gh-153494: Encode imaplib search criteria to the declared CHARSET
IMAP4.search(), sort(), thread() and the uid SORT/THREAD variants now encode str search criteria to the declared charset, so international search text can be passed as ordinary str. A criterion passed as bytes is sent unchanged, for use with a charset that Python has no codec for. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6c389c4 commit 94a8424

4 files changed

Lines changed: 114 additions & 6 deletions

File tree

Doc/library/imaplib.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,14 @@ An :class:`IMAP4` instance has the following methods:
584584
the ``UTF8=ACCEPT`` capability was enabled using the :meth:`enable`
585585
command.
586586

587+
A criterion passed as :class:`str` is encoded to *charset* (which must name
588+
a codec known to Python); pass :class:`bytes` to send a criterion that is
589+
already encoded, for example when *charset* is one that Python does not
590+
support.
591+
592+
.. versionchanged:: next
593+
``str`` search criteria are encoded to *charset*.
594+
587595
Example::
588596

589597
# M is a connected IMAP4 instance...
@@ -651,8 +659,14 @@ An :class:`IMAP4` instance has the following methods:
651659
the interpretation of strings in the searching criteria. It then returns the
652660
numbers of matching messages.
653661

662+
As with :meth:`search`, a *search_criterion* passed as :class:`str` is
663+
encoded to *charset*; pass :class:`bytes` to send one already encoded.
664+
654665
This is an ``IMAP4rev1`` extension command.
655666

667+
.. versionchanged:: next
668+
``str`` search criteria are encoded to *charset*.
669+
656670

657671
.. method:: IMAP4.starttls(ssl_context=None)
658672

@@ -729,8 +743,14 @@ An :class:`IMAP4` instance has the following methods:
729743
returns the matching messages threaded according to the specified threading
730744
algorithm.
731745

746+
As with :meth:`search`, a *search_criterion* passed as :class:`str` is
747+
encoded to *charset*; pass :class:`bytes` to send one already encoded.
748+
732749
This is an ``IMAP4rev1`` extension command.
733750

751+
.. versionchanged:: next
752+
``str`` search criteria are encoded to *charset*.
753+
734754

735755
.. method:: IMAP4.uid(command, arg[, ...])
736756

Lib/imaplib.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,11 +920,15 @@ def search(self, charset, *criteria):
920920
921921
'data' is space separated list of matching message numbers.
922922
If UTF8 is enabled, charset MUST be None.
923+
924+
A 'criteria' passed as str is encoded to 'charset'; pass bytes to
925+
send criteria that are already encoded.
923926
"""
924927
name = 'SEARCH'
925928
if charset is not None:
926929
if self.utf8_enabled:
927930
raise IMAP4.error("Non-None charset not valid in UTF8 mode")
931+
criteria = self._encode_criteria(charset, criteria)
928932
typ, dat = self._simple_command(name,
929933
'CHARSET', self._astring(charset), *criteria)
930934
else:
@@ -1000,6 +1004,7 @@ def sort(self, sort_criteria, charset, *search_criteria):
10001004
#if not name in self.capabilities: # Let the server decide!
10011005
# raise self.error('unimplemented extension command: %s' % name)
10021006
sort_criteria = self._set_quote(sort_criteria)
1007+
search_criteria = self._encode_criteria(charset, search_criteria)
10031008
if charset is not None:
10041009
charset = self._astring(charset)
10051010
typ, dat = self._simple_command(name, sort_criteria, charset, *search_criteria)
@@ -1068,6 +1073,7 @@ def thread(self, threading_algorithm, charset, *search_criteria):
10681073
(type, [data]) = <instance>.thread(threading_algorithm, charset, search_criteria, ...)
10691074
"""
10701075
name = 'THREAD'
1076+
search_criteria = self._encode_criteria(charset, search_criteria)
10711077
if charset is not None:
10721078
charset = self._astring(charset)
10731079
typ, dat = self._simple_command(name, self._atom(threading_algorithm),
@@ -1106,12 +1112,14 @@ def uid(self, command, *args):
11061112
self._set_quote(flags))
11071113
elif command == 'SORT':
11081114
sort_criteria, charset, *search_criteria = args
1115+
search_criteria = self._encode_criteria(charset, search_criteria)
11091116
if charset is not None:
11101117
charset = self._astring(charset)
11111118
args = (self._set_quote(sort_criteria), charset,
11121119
*search_criteria)
11131120
elif command == 'THREAD':
11141121
threading_algorithm, charset, *search_criteria = args
1122+
search_criteria = self._encode_criteria(charset, search_criteria)
11151123
if charset is not None:
11161124
charset = self._astring(charset)
11171125
args = (self._atom(threading_algorithm), charset,
@@ -1524,6 +1532,17 @@ def _fetch_parts(self, arg):
15241532
return arg
15251533
return self._set_quote(arg)
15261534

1535+
def _encode_criteria(self, charset, criteria):
1536+
# Encode str search criteria to the declared CHARSET so the bytes on
1537+
# the wire match it. bytes criteria are already encoded and pass
1538+
# through unchanged. charset is None when no CHARSET is sent.
1539+
if charset is None:
1540+
return criteria
1541+
if isinstance(charset, (bytes, bytearray)):
1542+
charset = str(charset, 'ascii')
1543+
return tuple(c.encode(charset) if isinstance(c, str) else c
1544+
for c in criteria)
1545+
15271546
def _quote(self, arg):
15281547
if isinstance(arg, str):
15291548
arg = bytes(arg, self._encoding)

Lib/test/test_imaplib.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,31 @@ def test_astring(self):
205205
self.assertEqual(m._astring('Entwürfe'), '"Entwürfe"'.encode())
206206
self.assertEqual(m._astring(b'Entw\xc3\xbcrfe'), b'"Entw\xc3\xbcrfe"')
207207

208+
def test_encode_criteria(self):
209+
m = imaplib.IMAP4.__new__(imaplib.IMAP4)
210+
enc = m._encode_criteria
211+
# No charset: criteria are returned unchanged.
212+
self.assertEqual(enc(None, ('TEXT', 'x')), ('TEXT', 'x'))
213+
# str criteria are encoded to the charset; ASCII is charset-independent.
214+
self.assertEqual(enc('UTF-8', ('TEXT', 'XXXXXX')), (b'TEXT', b'XXXXXX'))
215+
# Non-ASCII text is encoded to the declared charset, including charsets
216+
# other than ASCII, Latin-1 and UTF-8.
217+
self.assertEqual(enc('UTF-8', ('"café"',)), ('"café"'.encode('utf-8'),))
218+
self.assertEqual(enc('ISO-8859-1', ('"café"',)),
219+
('"café"'.encode('latin-1'),))
220+
self.assertEqual(enc('KOI8-U', ('"Київ"',)),
221+
('"Київ"'.encode('koi8-u'),))
222+
self.assertEqual(enc('SHIFT_JIS', ('"日本"',)),
223+
('"日本"'.encode('shift_jis'),))
224+
# bytes criteria are already encoded and pass through unchanged.
225+
self.assertEqual(enc('SHIFT_JIS', (b'"already"',)), (b'"already"',))
226+
# The charset name may itself be bytes.
227+
self.assertEqual(enc(b'UTF-8', ('"café"',)), ('"café"'.encode('utf-8'),))
228+
# A charset with no Python codec cannot encode str criteria; bytes
229+
# criteria must be used with such server-only charsets.
230+
self.assertRaises(LookupError, enc, 'NF_Z_62-010_(1973)', ('TEXT',))
231+
self.assertEqual(enc('NF_Z_62-010_(1973)', (b'TEXT',)), (b'TEXT',))
232+
208233
def test_astring_idempotent(self):
209234
# Quoting an already quoted argument should not change it, so that
210235
# quoting twice gives the same result as quoting once.
@@ -337,7 +362,11 @@ def handle(self):
337362
except StopIteration:
338363
self.continuation = None
339364
continue
340-
splitline = splitargs(line.decode().removesuffix('\r\n'))
365+
self.server.line = line
366+
# surrogateescape so a criterion encoded in a non-UTF-8 charset
367+
# does not crash the handler; tests inspect server.line for bytes.
368+
splitline = splitargs(line.decode('utf-8', 'surrogateescape')
369+
.removesuffix('\r\n'))
341370
tag = splitline[0]
342371
cmd = splitline[1]
343372
args = splitline[2:]
@@ -1494,7 +1523,17 @@ def test_search(self):
14941523
self.assertEqual(data, [b'43'])
14951524
self.assertEqual(server.args, ['CHARSET', 'UTF-8', 'TEXT', 'XXXXXX'])
14961525

1497-
typ, data = client.search('NF_Z_62-010_(1973)', 'TEXT', 'XXXXXX')
1526+
# A non-ASCII str criterion is encoded to the declared charset (KOI8-U
1527+
# here, which is not UTF-8, so check the encoded bytes on the wire).
1528+
response[:] = ['* SEARCH 43']
1529+
typ, data = client.search('KOI8-U', 'SUBJECT', '"Київ"')
1530+
self.assertEqual(typ, 'OK')
1531+
self.assertIn(b'CHARSET KOI8-U ', server.line)
1532+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1533+
1534+
# bytes criteria: NF_Z_62-010 has no Python codec, so this exercises
1535+
# charset-name quoting without criteria encoding.
1536+
typ, data = client.search('NF_Z_62-010_(1973)', b'TEXT', b'XXXXXX')
14981537
self.assertEqual(typ, 'OK')
14991538
self.assertEqual(server.args, ['CHARSET', '"NF_Z_62-010_(1973)"', 'TEXT', 'XXXXXX'])
15001539

@@ -1549,10 +1588,16 @@ def test_sort(self):
15491588
self.assertEqual(data, [br''])
15501589
self.assertEqual(server.args, ['(SUBJECT)', 'US-ASCII', 'TEXT', '"not in mailbox"'])
15511590

1552-
typ, data = client.sort('SUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"not in mailbox"')
1591+
typ, data = client.sort('SUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"not in mailbox"')
15531592
self.assertEqual(typ, 'OK')
15541593
self.assertEqual(server.args, ['(SUBJECT)', '"NF_Z_62-010_(1973)"', 'TEXT', '"not in mailbox"'])
15551594

1595+
# A non-ASCII str criterion is encoded to the declared charset.
1596+
response[:] = ['* SORT']
1597+
typ, data = client.sort('(SUBJECT)', 'KOI8-U', 'TEXT', '"Київ"')
1598+
self.assertEqual(typ, 'OK')
1599+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1600+
15561601
def test_uid_sort(self):
15571602
response = []
15581603
client, server = self._setup(make_simple_handler('UID', response,
@@ -1577,10 +1622,16 @@ def test_uid_sort(self):
15771622
self.assertEqual(data, [br''])
15781623
self.assertEqual(server.args, ['SORT', '(SUBJECT)', 'US-ASCII', 'TEXT', '"not in mailbox"'])
15791624

1580-
typ, data = client.uid('sort', 'SUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"not in mailbox"')
1625+
typ, data = client.uid('sort', 'SUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"not in mailbox"')
15811626
self.assertEqual(typ, 'OK')
15821627
self.assertEqual(server.args, ['SORT', '(SUBJECT)', '"NF_Z_62-010_(1973)"', 'TEXT', '"not in mailbox"'])
15831628

1629+
# A non-ASCII str criterion is encoded to the declared charset.
1630+
response[:] = ['* SORT']
1631+
typ, data = client.uid('sort', '(SUBJECT)', 'KOI8-U', 'TEXT', '"Київ"')
1632+
self.assertEqual(typ, 'OK')
1633+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1634+
15841635
def test_thread(self):
15851636
response = []
15861637
client, server = self._setup(make_simple_handler('THREAD', response))
@@ -1618,10 +1669,16 @@ def test_thread(self):
16181669
b'(199)(200 202)(201)(203)(204)(205 206 207)(208)'])
16191670
self.assertEqual(server.args, ['ORDEREDSUBJECT', 'US-ASCII', 'TEXT', '"gewp"'])
16201671

1621-
typ, data = client.thread('ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"gewp"')
1672+
typ, data = client.thread('ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"gewp"')
16221673
self.assertEqual(typ, 'OK')
16231674
self.assertEqual(server.args, ['ORDEREDSUBJECT', '"NF_Z_62-010_(1973)"', 'TEXT', '"gewp"'])
16241675

1676+
# A non-ASCII str criterion is encoded to the declared charset.
1677+
response[:] = ['* THREAD (1)']
1678+
typ, data = client.thread('ORDEREDSUBJECT', 'KOI8-U', 'TEXT', '"Київ"')
1679+
self.assertEqual(typ, 'OK')
1680+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1681+
16251682
def test_uid_thread(self):
16261683
response = []
16271684
client, server = self._setup(make_simple_handler('UID', response,
@@ -1660,10 +1717,16 @@ def test_uid_thread(self):
16601717
b'(199)(200 202)(201)(203)(204)(205 206 207)(208)'])
16611718
self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', 'US-ASCII', 'TEXT', '"gewp"'])
16621719

1663-
typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"gewp"')
1720+
typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"gewp"')
16641721
self.assertEqual(typ, 'OK')
16651722
self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', '"NF_Z_62-010_(1973)"', 'TEXT', '"gewp"'])
16661723

1724+
# A non-ASCII str criterion is encoded to the declared charset.
1725+
response[:] = ['* THREAD (1)']
1726+
typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'KOI8-U', 'TEXT', '"Київ"')
1727+
self.assertEqual(typ, 'OK')
1728+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1729+
16671730
def test_delete(self):
16681731
client, server = self._setup(make_simple_handler('DELETE'))
16691732
client.login('user', 'pass')
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
:meth:`imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.sort` and
2+
:meth:`~imaplib.IMAP4.thread` (and the corresponding ``uid`` commands) now
3+
encode :class:`str` search criteria to the declared *charset*, so
4+
international search text can be passed as ordinary :class:`str`. A criterion
5+
passed as :class:`bytes` is sent unchanged, for use with a charset that Python
6+
has no codec for.

0 commit comments

Comments
 (0)