Skip to content

Commit 5aafbea

Browse files
gh-153494: Encode imaplib search criteria to the declared CHARSET (GH-153495)
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 8a32914 commit 5aafbea

5 files changed

Lines changed: 129 additions & 6 deletions

File tree

Doc/library/imaplib.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,13 @@ An :class:`IMAP4` instance has the following methods:
610610
If *uid* is true, the message numbers in the response are UIDs
611611
(``UID SEARCH``).
612612

613+
A criterion passed as :class:`str` is encoded to *charset*
614+
(which must name a codec known to Python);
615+
pass :class:`bytes` to send a criterion that is already encoded,
616+
for example when *charset* is one that Python does not support.
617+
When *charset* is ``None`` (as it must be under ``UTF8=ACCEPT``),
618+
the criterion is sent using the connection's encoding instead.
619+
613620
Example::
614621

615622
# M is a connected IMAP4 instance...
@@ -621,6 +628,9 @@ An :class:`IMAP4` instance has the following methods:
621628
.. versionchanged:: next
622629
Added the *uid* parameter.
623630

631+
.. versionchanged:: next
632+
``str`` search criteria are encoded to *charset*.
633+
624634

625635
.. method:: IMAP4.select(mailbox='INBOX', readonly=False)
626636

@@ -682,11 +692,18 @@ An :class:`IMAP4` instance has the following methods:
682692

683693
If *uid* is true, the message numbers in the response are UIDs (``UID SORT``).
684694

695+
As with :meth:`search`,
696+
a *search_criterion* passed as :class:`str` is encoded to *charset*;
697+
pass :class:`bytes` to send one already encoded.
698+
685699
This is an ``IMAP4rev1`` extension command.
686700

687701
.. versionchanged:: next
688702
Added the *uid* parameter.
689703

704+
.. versionchanged:: next
705+
``str`` search criteria are encoded to *charset*.
706+
690707

691708
.. method:: IMAP4.starttls(ssl_context=None)
692709

@@ -772,11 +789,18 @@ An :class:`IMAP4` instance has the following methods:
772789
If *uid* is true, the message numbers in the response are UIDs
773790
(``UID THREAD``).
774791

792+
As with :meth:`search`,
793+
a *search_criterion* passed as :class:`str` is encoded to *charset*;
794+
pass :class:`bytes` to send one already encoded.
795+
775796
This is an ``IMAP4rev1`` extension command.
776797

777798
.. versionchanged:: next
778799
Added the *uid* parameter.
779800

801+
.. versionchanged:: next
802+
``str`` search criteria are encoded to *charset*.
803+
780804

781805
.. method:: IMAP4.uid(command, arg[, ...])
782806

Doc/whatsnew/3.16.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,14 @@ imaplib
276276
:meth:`~imaplib.IMAP4.uid`.
277277
(Contributed by Serhiy Storchaka in :gh:`153502`.)
278278

279+
* :meth:`~imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.sort`
280+
and :meth:`~imaplib.IMAP4.thread` (and the corresponding ``uid`` commands)
281+
now encode :class:`str` search criteria to the declared *charset*,
282+
so international search text can be passed as an ordinary :class:`str`.
283+
When *charset* is ``None`` (as it must be under ``UTF8=ACCEPT``),
284+
the criteria are sent using the connection encoding instead.
285+
(Contributed by Serhiy Storchaka in :gh:`153494`.)
286+
279287

280288
ipaddress
281289
---------

Lib/imaplib.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,11 +950,15 @@ def search(self, charset, *criteria, uid=False):
950950
If UTF8 is enabled, charset MUST be None.
951951
If 'uid' is true, the message numbers in the response are UIDs
952952
(UID SEARCH).
953+
954+
A 'criteria' passed as str is encoded to 'charset'; pass bytes to
955+
send criteria that are already encoded.
953956
"""
954957
name = 'SEARCH'
955958
if charset is not None:
956959
if self.utf8_enabled:
957960
raise IMAP4.error("Non-None charset not valid in UTF8 mode")
961+
criteria = self._encode_criteria(charset, criteria)
958962
args = ('CHARSET', self._astring(charset), *criteria)
959963
else:
960964
args = criteria
@@ -1036,6 +1040,7 @@ def sort(self, sort_criteria, charset, *search_criteria, uid=False):
10361040
#if not name in self.capabilities: # Let the server decide!
10371041
# raise self.error('unimplemented extension command: %s' % name)
10381042
sort_criteria = self._set_quote(sort_criteria)
1043+
search_criteria = self._encode_criteria(charset, search_criteria)
10391044
if charset is not None:
10401045
charset = self._astring(charset)
10411046
args = (sort_criteria, charset, *search_criteria)
@@ -1117,6 +1122,7 @@ def thread(self, threading_algorithm, charset, *search_criteria, uid=False):
11171122
(UID THREAD).
11181123
"""
11191124
name = 'THREAD'
1125+
search_criteria = self._encode_criteria(charset, search_criteria)
11201126
if charset is not None:
11211127
charset = self._astring(charset)
11221128
args = (self._atom(threading_algorithm), charset, *search_criteria)
@@ -1158,12 +1164,14 @@ def uid(self, command, *args):
11581164
self._set_quote(flags))
11591165
elif command == 'SORT':
11601166
sort_criteria, charset, *search_criteria = args
1167+
search_criteria = self._encode_criteria(charset, search_criteria)
11611168
if charset is not None:
11621169
charset = self._astring(charset)
11631170
args = (self._set_quote(sort_criteria), charset,
11641171
*search_criteria)
11651172
elif command == 'THREAD':
11661173
threading_algorithm, charset, *search_criteria = args
1174+
search_criteria = self._encode_criteria(charset, search_criteria)
11671175
if charset is not None:
11681176
charset = self._astring(charset)
11691177
args = (self._atom(threading_algorithm), charset,
@@ -1576,6 +1584,17 @@ def _fetch_parts(self, arg):
15761584
return arg
15771585
return self._set_quote(arg)
15781586

1587+
def _encode_criteria(self, charset, criteria):
1588+
# Encode str search criteria to the declared CHARSET so the bytes on
1589+
# the wire match it. bytes criteria are already encoded and pass
1590+
# through unchanged. charset is None when no CHARSET is sent.
1591+
if charset is None:
1592+
return criteria
1593+
if isinstance(charset, (bytes, bytearray)):
1594+
charset = str(charset, 'ascii')
1595+
return tuple(c.encode(charset) if isinstance(c, str) else c
1596+
for c in criteria)
1597+
15791598
def _quote(self, arg):
15801599
if isinstance(arg, str):
15811600
arg = bytes(arg, self._encoding)

Lib/test/test_imaplib.py

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,32 @@ 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 codec at all (not even via the iconv codec) cannot
229+
# encode str criteria; bytes criteria must be used with such
230+
# server-only charsets.
231+
self.assertRaises(LookupError, enc, 'no-such-charset', ('TEXT',))
232+
self.assertEqual(enc('no-such-charset', (b'TEXT',)), (b'TEXT',))
233+
208234
def test_astring_idempotent(self):
209235
# Quoting an already quoted argument should not change it, so that
210236
# quoting twice gives the same result as quoting once.
@@ -337,7 +363,11 @@ def handle(self):
337363
except StopIteration:
338364
self.continuation = None
339365
continue
340-
splitline = splitargs(line.decode().removesuffix('\r\n'))
366+
self.server.line = line
367+
# surrogateescape so a criterion encoded in a non-UTF-8 charset
368+
# does not crash the handler; tests inspect server.line for bytes.
369+
splitline = splitargs(line.decode('utf-8', 'surrogateescape')
370+
.removesuffix('\r\n'))
341371
tag = splitline[0]
342372
cmd = splitline[1]
343373
args = splitline[2:]
@@ -1546,7 +1576,17 @@ def test_search(self):
15461576
self.assertEqual(data, [b'43'])
15471577
self.assertEqual(server.args, ['CHARSET', 'UTF-8', 'TEXT', 'XXXXXX'])
15481578

1549-
typ, data = client.search('NF_Z_62-010_(1973)', 'TEXT', 'XXXXXX')
1579+
# A non-ASCII str criterion is encoded to the declared charset (KOI8-U
1580+
# here, which is not UTF-8, so check the encoded bytes on the wire).
1581+
response[:] = ['* SEARCH 43']
1582+
typ, data = client.search('KOI8-U', 'SUBJECT', '"Київ"')
1583+
self.assertEqual(typ, 'OK')
1584+
self.assertIn(b'CHARSET KOI8-U ', server.line)
1585+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1586+
1587+
# bytes criteria keep this focused on charset-name quoting (the
1588+
# parentheses force the name to be quoted) without criteria encoding.
1589+
typ, data = client.search('NF_Z_62-010_(1973)', b'TEXT', b'XXXXXX')
15501590
self.assertEqual(typ, 'OK')
15511591
self.assertEqual(server.args, ['CHARSET', '"NF_Z_62-010_(1973)"', 'TEXT', 'XXXXXX'])
15521592

@@ -1616,10 +1656,16 @@ def test_sort(self):
16161656
self.assertEqual(data, [br''])
16171657
self.assertEqual(server.args, ['(SUBJECT)', 'US-ASCII', 'TEXT', '"not in mailbox"'])
16181658

1619-
typ, data = client.sort('SUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"not in mailbox"')
1659+
typ, data = client.sort('SUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"not in mailbox"')
16201660
self.assertEqual(typ, 'OK')
16211661
self.assertEqual(server.args, ['(SUBJECT)', '"NF_Z_62-010_(1973)"', 'TEXT', '"not in mailbox"'])
16221662

1663+
# A non-ASCII str criterion is encoded to the declared charset.
1664+
response[:] = ['* SORT']
1665+
typ, data = client.sort('(SUBJECT)', 'KOI8-U', 'TEXT', '"Київ"')
1666+
self.assertEqual(typ, 'OK')
1667+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1668+
16231669
def test_uid_sort(self):
16241670
response = []
16251671
client, server = self._setup(make_simple_handler('UID', response,
@@ -1644,10 +1690,16 @@ def test_uid_sort(self):
16441690
self.assertEqual(data, [br''])
16451691
self.assertEqual(server.args, ['SORT', '(SUBJECT)', 'US-ASCII', 'TEXT', '"not in mailbox"'])
16461692

1647-
typ, data = client.uid('sort', 'SUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"not in mailbox"')
1693+
typ, data = client.uid('sort', 'SUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"not in mailbox"')
16481694
self.assertEqual(typ, 'OK')
16491695
self.assertEqual(server.args, ['SORT', '(SUBJECT)', '"NF_Z_62-010_(1973)"', 'TEXT', '"not in mailbox"'])
16501696

1697+
# A non-ASCII str criterion is encoded to the declared charset.
1698+
response[:] = ['* SORT']
1699+
typ, data = client.uid('sort', '(SUBJECT)', 'KOI8-U', 'TEXT', '"Київ"')
1700+
self.assertEqual(typ, 'OK')
1701+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1702+
16511703
# The uid=True keyword is a shorthand for uid('SORT', ...).
16521704
response[:] = ['* SORT 2 84 882']
16531705
typ, data = client.sort('(SUBJECT)', 'UTF-8', 'SINCE', '1-Feb-1994', uid=True)
@@ -1692,10 +1744,16 @@ def test_thread(self):
16921744
b'(199)(200 202)(201)(203)(204)(205 206 207)(208)'])
16931745
self.assertEqual(server.args, ['ORDEREDSUBJECT', 'US-ASCII', 'TEXT', '"gewp"'])
16941746

1695-
typ, data = client.thread('ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"gewp"')
1747+
typ, data = client.thread('ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"gewp"')
16961748
self.assertEqual(typ, 'OK')
16971749
self.assertEqual(server.args, ['ORDEREDSUBJECT', '"NF_Z_62-010_(1973)"', 'TEXT', '"gewp"'])
16981750

1751+
# A non-ASCII str criterion is encoded to the declared charset.
1752+
response[:] = ['* THREAD (1)']
1753+
typ, data = client.thread('ORDEREDSUBJECT', 'KOI8-U', 'TEXT', '"Київ"')
1754+
self.assertEqual(typ, 'OK')
1755+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1756+
16991757
def test_uid_thread(self):
17001758
response = []
17011759
client, server = self._setup(make_simple_handler('UID', response,
@@ -1734,10 +1792,16 @@ def test_uid_thread(self):
17341792
b'(199)(200 202)(201)(203)(204)(205 206 207)(208)'])
17351793
self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', 'US-ASCII', 'TEXT', '"gewp"'])
17361794

1737-
typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"gewp"')
1795+
typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"gewp"')
17381796
self.assertEqual(typ, 'OK')
17391797
self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', '"NF_Z_62-010_(1973)"', 'TEXT', '"gewp"'])
17401798

1799+
# A non-ASCII str criterion is encoded to the declared charset.
1800+
response[:] = ['* THREAD (1)']
1801+
typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'KOI8-U', 'TEXT', '"Київ"')
1802+
self.assertEqual(typ, 'OK')
1803+
self.assertIn('"Київ"'.encode('koi8-u'), server.line)
1804+
17411805
# The uid=True keyword is a shorthand for uid('THREAD', ...).
17421806
response[:] = ['* THREAD (166)(167)(168)']
17431807
typ, data = client.thread('ORDEREDSUBJECT', 'UTF-8', 'SINCE', '5-MAR-2000',
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
:meth:`imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.sort`
2+
and :meth:`~imaplib.IMAP4.thread` (and the corresponding ``uid`` commands)
3+
now encode :class:`str` search criteria to the declared *charset*,
4+
so international search text can be passed as ordinary :class:`str`.
5+
When *charset* is ``None`` (as it must be under ``UTF8=ACCEPT``),
6+
the criteria are sent using the connection encoding instead.
7+
A criterion passed as :class:`bytes` is sent unchanged,
8+
for use with a charset that Python has no codec for.

0 commit comments

Comments
 (0)