From d916684cb901c8dbc765697c62a8538d11ab1a04 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 6 Jul 2026 17:06:14 +0300 Subject: [PATCH] gh-153521: Support structured arguments in imaplib commands Command methods now accept a structured *message_set* (an integer, or a sequence of integers, (start, stop) ranges and range objects) and lists of flags or other atoms in place of preformatted parenthesized strings. The search, fetch, sort, thread and uid methods gain a keyword-only *params* argument that substitutes and quotes '?' placeholders in their value-bearing arguments, in the manner of sqlite3 parameter substitution. Co-Authored-By: Claude Opus 4.8 --- Doc/library/imaplib.rst | 106 ++++++++-- Doc/whatsnew/3.16.rst | 11 ++ Lib/imaplib.py | 147 +++++++++++++- Lib/test/test_imaplib.py | 182 ++++++++++++++++++ ...-07-06-14-30-00.gh-issue-153521.iMaP47.rst | 7 + 5 files changed, 429 insertions(+), 24 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-06-14-30-00.gh-issue-153521.iMaP47.rst diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 7b79d81790b667..afdc0f0daf8cc6 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -217,6 +217,62 @@ of message numbers (``'2:4'``), or a group of non-contiguous ranges separated by commas (``'1:3,6:9'``). A range can contain an asterisk to indicate an infinite upper bound (``'3:*'``). +It may also be given in a structured form: +an integer, +or a sequence whose items are integers, +``(start, stop)`` range tuples (where ``None`` or ``'*'`` stands for the last +message), +or :class:`range` objects. +For example, ``[1, (3, 5), 8]`` and ``[range(1, 6), 8]`` are both equivalent to +``'1,3:5,8'``. + +.. versionchanged:: next + Added support for the structured *message_set*. + +Arguments that are parenthesized lists of atoms --- +such as the *flag_list* argument of :meth:`~IMAP4.store` and the *flags* +argument of :meth:`~IMAP4.append`, +the *names* argument of :meth:`~IMAP4.status`, +the *sort_criteria* argument of :meth:`~IMAP4.sort`, +or the *message_parts* argument of :meth:`~IMAP4.fetch` --- +can be passed as a sequence of strings instead of a preformatted string. +For example, ``[r'\Seen', r'\Answered']`` is sent as ``(\Seen \Answered)``. + +.. versionchanged:: next + Added support for passing these arguments as a sequence. + +.. _imap4-params: + +The value-bearing arguments of the search and fetch commands must otherwise be +quoted by hand. +Instead, they may contain ``?`` placeholders that are substituted, and quoted +as required, from a *params* keyword argument, +in the manner of :mod:`sqlite3` parameter substitution:: + + # SEARCH FROM me@example.com SUBJECT "trip report" + M.search(None, 'FROM ? SUBJECT ?', params=['me@example.com', 'trip report']) + + # FETCH 1:5 (FLAGS BODY[HEADER.FIELDS (DATE FROM)]) + M.fetch('1:5', 'FLAGS BODY[HEADER.FIELDS ?]', params=[['DATE', 'FROM']]) + +The placeholders are: + +* ``?`` --- an astring: a string (quoted if necessary), an integer, or a + list of them (sent as a parenthesized list); +* ``?f`` --- a flag or a list of flags, sent verbatim without quoting; +* ``?s`` --- a *message_set* in the structured form described above. + +``??`` stands for a literal ``?``. + +Substitution is only performed when *params* is given, +so an existing call that contains a literal ``?`` is unaffected. +The *params* keyword is accepted by :meth:`~IMAP4.search`, +:meth:`~IMAP4.fetch`, :meth:`~IMAP4.sort`, :meth:`~IMAP4.thread` and +:meth:`~IMAP4.uid`. + +.. versionadded:: next + The *params* keyword argument. + An :class:`IMAP4` instance has the following methods: @@ -324,7 +380,7 @@ An :class:`IMAP4` instance has the following methods: Added the *message_set* and *uid* parameters. -.. method:: IMAP4.fetch(message_set, message_parts, *, uid=False) +.. method:: IMAP4.fetch(message_set, message_parts, *, uid=False, params=None) Fetch (parts of) messages. *message_parts* should be a string of message part names enclosed within parentheses, eg: ``"(UID BODY[TEXT])"``. Returned data @@ -333,8 +389,11 @@ An :class:`IMAP4` instance has the following methods: If *uid* is true, *message_set* is a set of UIDs and the message numbers in the response are UIDs (``UID FETCH``). + If *params* is given, ``?`` placeholders in *message_parts* are substituted + with the quoted parameters (see :ref:`the placeholders `). + .. versionchanged:: next - Added the *uid* parameter. + Added the *params* and *uid* parameters. .. method:: IMAP4.getacl(mailbox) @@ -598,7 +657,7 @@ An :class:`IMAP4` instance has the following methods: code, instead of the usual type. -.. method:: IMAP4.search(charset, criterion[, ...], *, uid=False) +.. method:: IMAP4.search(charset, criterion[, ...], *, uid=False, params=None) Search mailbox for matching messages. *charset* may be ``None``, in which case no ``CHARSET`` will be specified in the request to the server. The IMAP @@ -617,18 +676,22 @@ An :class:`IMAP4` instance has the following methods: When *charset* is ``None`` (as it must be under ``UTF8=ACCEPT``), the criterion is sent using the connection's encoding instead. + If *params* is given, ``?`` placeholders in the criteria are substituted + with the quoted parameters (see :ref:`the placeholders `). + Example:: # M is a connected IMAP4 instance... - typ, msgnums = M.search(None, 'FROM', '"LDJ"') + typ, msgnums = M.search(None, 'FROM', '"John Smith"') # or: - typ, msgnums = M.search(None, '(FROM "LDJ")') + typ, msgnums = M.search(None, '(FROM "John Smith")') - .. versionchanged:: next - Added the *uid* parameter. + # or, letting the module quote the value: + typ, msgnums = M.search(None, 'FROM ?', params=['John Smith']) .. versionchanged:: next + Added the *params* and *uid* parameters. ``str`` search criteria are encoded to *charset*. @@ -675,7 +738,7 @@ An :class:`IMAP4` instance has the following methods: Returns socket instance used to connect to server. -.. method:: IMAP4.sort(sort_criteria, charset, search_criterion[, ...], *, uid=False) +.. method:: IMAP4.sort(sort_criteria, charset, search_criterion[, ...], *, uid=False, params=None) The ``sort`` command is a variant of ``search`` with sorting semantics for the results. Returned data contains a space separated list of matching message @@ -696,12 +759,13 @@ An :class:`IMAP4` instance has the following methods: a *search_criterion* passed as :class:`str` is encoded to *charset*; pass :class:`bytes` to send one already encoded. - This is an ``IMAP4rev1`` extension command. + If *params* is given, ``?`` placeholders in the search criteria are + substituted with the quoted parameters (see :ref:`the placeholders `). - .. versionchanged:: next - Added the *uid* parameter. + This is an ``IMAP4rev1`` extension command. .. versionchanged:: next + Added the *params* and *uid* parameters. ``str`` search criteria are encoded to *charset*. @@ -745,7 +809,7 @@ An :class:`IMAP4` instance has the following methods: typ, data = M.search(None, 'ALL') for num in data[0].split(): - M.store(num, '+FLAGS', '\\Deleted') + M.store(num, '+FLAGS', r'\Deleted') M.expunge() .. note:: @@ -768,7 +832,7 @@ An :class:`IMAP4` instance has the following methods: Subscribe to new mailbox. -.. method:: IMAP4.thread(threading_algorithm, charset, search_criterion[, ...], *, uid=False) +.. method:: IMAP4.thread(threading_algorithm, charset, search_criterion[, ...], *, uid=False, params=None) The ``thread`` command is a variant of ``search`` with threading semantics for the results. Returned data contains a space separated list of thread members. @@ -793,22 +857,30 @@ An :class:`IMAP4` instance has the following methods: a *search_criterion* passed as :class:`str` is encoded to *charset*; pass :class:`bytes` to send one already encoded. - This is an ``IMAP4rev1`` extension command. + If *params* is given, ``?`` placeholders in the search criteria are + substituted with the quoted parameters (see :ref:`the placeholders `). - .. versionchanged:: next - Added the *uid* parameter. + This is an ``IMAP4rev1`` extension command. .. versionchanged:: next + Added the *params* and *uid* parameters. ``str`` search criteria are encoded to *charset*. -.. method:: IMAP4.uid(command, arg[, ...]) +.. method:: IMAP4.uid(command, arg[, ...], *, params=None) Execute command args with messages identified by UID, rather than message number. Returns response appropriate to command. At least one argument must be supplied; if none are provided, the server will return an error and an exception will be raised. + If *params* is given, ``?`` placeholders in the ``SEARCH``, ``SORT`` and + ``THREAD`` criteria or in the ``FETCH`` parts are substituted with the quoted + parameters (see :ref:`the placeholders `). + + .. versionchanged:: next + Added the *params* parameter. + .. method:: IMAP4.unsubscribe(mailbox) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 06c3dbb2f0f1ad..d3e621e62a18ca 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -284,6 +284,17 @@ imaplib the criteria are sent using the connection encoding instead. (Contributed by Serhiy Storchaka in :gh:`153494`.) +* Command methods now accept structured arguments, + so the module takes care of quoting instead of the caller. + A *message_set* and lists of flags or other atoms + can be passed as sequences instead of preformatted strings, + and the :meth:`~imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.fetch`, + :meth:`~imaplib.IMAP4.sort`, :meth:`~imaplib.IMAP4.thread` and + :meth:`~imaplib.IMAP4.uid` methods accept a *params* keyword argument + that substitutes and quotes ``?`` placeholders, + in the manner of :mod:`sqlite3` parameter substitution. + (Contributed by Serhiy Storchaka in :gh:`153521`.) + ipaddress --------- diff --git a/Lib/imaplib.py b/Lib/imaplib.py index ed5b528976448a..a0d04a878f152a 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -133,6 +133,7 @@ # Only NUL, CR and LF are unsafe (they cannot be represented even in # a quoted string); other control characters are sent quoted. _control_chars = re.compile(b'[\x00\r\n]') +_control_chars_str = re.compile('[\x00\r\n]') _non_astring_char = re.compile(br'[(){ \x00-\x1f\x7f-\xff%*\\"]') _non_list_char = re.compile(br'[(){ \x00-\x1f\x7f-\xff\\"]') _quoted = re.compile(br'"(?:[^"\\]|\\.)*+"') @@ -144,6 +145,98 @@ def _paren_depth(data, depth=0): return depth + data.count(b'(') - data.count(b')') +def _seq_number(n): + # A single message number; None or '*' is the last message. + if n is None or n == '*': + return '*' + if not isinstance(n, int): + raise TypeError('message number must be an integer, not %s' + % type(n).__name__) + return str(n) + + +def _seq_range(item): + if isinstance(item, range): + if item.step == 1 and len(item): + item = (item.start, item[-1]) + else: + return ','.join(map(str, item)) + if isinstance(item, tuple): + start, stop = item + return '%s:%s' % (_seq_number(start), _seq_number(stop)) + return _seq_number(item) + + +def _format_sequence_set(arg): + if isinstance(arg, (int, str)): + return str(arg) + # A sequence of message numbers and ranges. + return ','.join(map(_seq_range, arg)) + + +# Characters that prevent a string from being sent as a bare atom. +_astring_special = re.compile(r'[(){ %*\\"\x00-\x1f\x7f-\U0010ffff]') +# A flag: an atom, optionally prefixed by a backslash. +_flag = re.compile(r'\\?[^(){ %*"\\\]\x00-\x1f\x7f-\U0010ffff]+') +# A placeholder in a format string: '?', '?f', '?s' or the escape '??'. +_placeholder = re.compile(r'\?[?fs]?') + + +def _format_astring(value): + if isinstance(value, (list, tuple)): + return '(' + ' '.join(map(_format_astring, value)) + ')' + if isinstance(value, bool): + raise TypeError('a boolean is not a valid IMAP4 string') + if isinstance(value, int): + return str(value) + if isinstance(value, (bytes, bytearray)): + value = str(value, 'ascii') + elif not isinstance(value, str): + raise TypeError('expected a string, an integer or a list, not %s' + % type(value).__name__) + if value and _astring_special.search(value) is None: + return value # an atom, sent unquoted + if _control_chars_str.search(value): + raise ValueError('NUL, CR and LF cannot be represented inline: %r' + % value) + return '"' + value.replace('\\', r'\\').replace('"', r'\"') + '"' + + +def _format_flags(value): + if isinstance(value, (list, tuple)): + return '(' + ' '.join(map(_format_flags, value)) + ')' + if isinstance(value, (bytes, bytearray)): + value = str(value, 'ascii') + elif not isinstance(value, str): + raise TypeError('expected a flag string or a list, not %s' + % type(value).__name__) + if not _flag.fullmatch(value): + raise ValueError('invalid flag: %r' % value) + return value + + +def _substitute(format, params): + params = iter(params) + def replace(match): + spec = match.group() + if spec == '??': # an escaped literal '?' + return '?' + try: + value = next(params) + except StopIteration: + raise TypeError('not enough parameters for the format string') \ + from None + if spec == '?f': # a flag or a list of flags + return _format_flags(value) + if spec == '?s': # a message sequence set + return _format_sequence_set(value) + return _format_astring(value) # an astring or a list of astrings + result = _placeholder.sub(replace, format) + for value in params: + raise TypeError('too many parameters for the format string') + return result + + class IMAP4: r"""IMAP4 client class. @@ -665,7 +758,7 @@ def expunge(self, message_set=None, *, uid=False): return self._untagged_response(typ, dat, name) - def fetch(self, message_set, message_parts, *, uid=False): + def fetch(self, message_set, message_parts, *, uid=False, params=None): """Fetch (parts of) messages. (typ, [data, ...]) = .fetch(message_set, message_parts) @@ -673,12 +766,17 @@ def fetch(self, message_set, message_parts, *, uid=False): 'message_parts' should be a string of selected parts enclosed in parentheses, eg: "(UID BODY[TEXT])". + If 'params' is given, '?' placeholders in 'message_parts' are + substituted with the quoted parameters. + 'data' are tuples of message part envelope and data. If 'uid' is true, 'message_set' is a set of UIDs and the message numbers in the response are UIDs (UID FETCH). """ name = 'FETCH' + if params is not None: + message_parts = _substitute(message_parts, params) args = (self._sequence_set(message_set), self._fetch_parts(message_parts)) if uid: @@ -941,7 +1039,7 @@ def rename(self, oldmailbox, newmailbox): self._mailbox(newmailbox)) - def search(self, charset, *criteria, uid=False): + def search(self, charset, *criteria, uid=False, params=None): """Search mailbox for matching messages. (typ, [data]) = .search(charset, criterion, ...) @@ -953,8 +1051,13 @@ def search(self, charset, *criteria, uid=False): A 'criteria' passed as str is encoded to 'charset'; pass bytes to send criteria that are already encoded. + + If 'params' is given, '?' placeholders in the criteria are + substituted with the quoted parameters. """ name = 'SEARCH' + if params is not None: + criteria = (_substitute(' '.join(criteria), params),) if charset is not None: if self.utf8_enabled: raise IMAP4.error("Non-None charset not valid in UTF8 mode") @@ -1028,18 +1131,24 @@ def setquota(self, root, limits): return self._untagged_response(typ, dat, 'QUOTA') - def sort(self, sort_criteria, charset, *search_criteria, uid=False): + def sort(self, sort_criteria, charset, *search_criteria, uid=False, + params=None): """IMAP4rev1 extension SORT command. (typ, [data]) = .sort(sort_criteria, charset, search_criteria, ...) If 'uid' is true, the message numbers in the response are UIDs (UID SORT). + + If 'params' is given, '?' placeholders in the search criteria are + substituted with the quoted parameters. """ name = 'SORT' #if not name in self.capabilities: # Let the server decide! # raise self.error('unimplemented extension command: %s' % name) sort_criteria = self._set_quote(sort_criteria) + if params is not None: + search_criteria = (_substitute(' '.join(search_criteria), params),) search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) @@ -1113,15 +1222,21 @@ def subscribe(self, mailbox): return self._simple_command('SUBSCRIBE', self._mailbox(mailbox)) - def thread(self, threading_algorithm, charset, *search_criteria, uid=False): + def thread(self, threading_algorithm, charset, *search_criteria, uid=False, + params=None): """IMAPrev1 extension THREAD command. (type, [data]) = .thread(threading_algorithm, charset, search_criteria, ...) If 'uid' is true, the message numbers in the response are UIDs (UID THREAD). + + If 'params' is given, '?' placeholders in the search criteria are + substituted with the quoted parameters. """ name = 'THREAD' + if params is not None: + search_criteria = (_substitute(' '.join(search_criteria), params),) search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) @@ -1133,13 +1248,17 @@ def thread(self, threading_algorithm, charset, *search_criteria, uid=False): return self._untagged_response(typ, dat, name) - def uid(self, command, *args): + def uid(self, command, *args, params=None): """Execute "command arg ..." with messages identified by UID, rather than message number. (typ, [data]) = .uid(command, arg1, arg2, ...) Returns response appropriate to 'command'. + + If 'params' is given, '?' placeholders in the SEARCH, SORT and + THREAD criteria or in the FETCH parts are substituted with the + quoted parameters. """ command = command.upper() if not command in Commands: @@ -1156,6 +1275,8 @@ def uid(self, command, *args): self._mailbox(new_mailbox)) elif command == 'FETCH': message_set, message_parts = args + if params is not None: + message_parts = _substitute(message_parts, params) args = (self._sequence_set(message_set), self._fetch_parts(message_parts)) elif command == 'STORE': @@ -1164,6 +1285,9 @@ def uid(self, command, *args): self._set_quote(flags)) elif command == 'SORT': sort_criteria, charset, *search_criteria = args + if params is not None: + search_criteria = (_substitute(' '.join(search_criteria), + params),) search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) @@ -1171,11 +1295,16 @@ def uid(self, command, *args): *search_criteria) elif command == 'THREAD': threading_algorithm, charset, *search_criteria = args + if params is not None: + search_criteria = (_substitute(' '.join(search_criteria), + params),) search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) args = (self._atom(threading_algorithm), charset, *search_criteria) + elif command == 'SEARCH' and params is not None: + args = (_substitute(' '.join(args), params),) typ, dat = self._simple_command(name, self._atom(command), *args) if command in ('SEARCH', 'SORT', 'THREAD'): name = command @@ -1570,9 +1699,13 @@ def _atom(self, arg): return arg def _sequence_set(self, arg): - return arg + return _format_sequence_set(arg) def _set_quote(self, arg): + if not isinstance(arg, str): + # A sequence of atoms (flags, criteria, item names, etc.); + # wrap them in parentheses as a single argument. + return '(' + ' '.join(arg) + ')' if arg and arg[0] == '(' and arg[-1] == ')': return arg return '(' + arg + ')' @@ -1580,7 +1713,7 @@ def _set_quote(self, arg): def _fetch_parts(self, arg): # "ALL", "FULL" and "FAST" are macros, not data item names; # they cannot be enclosed in parentheses. - if arg.upper() in ('ALL', 'FULL', 'FAST'): + if isinstance(arg, str) and arg.upper() in ('ALL', 'FULL', 'FAST'): return arg return self._set_quote(arg) diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 3eaca67299e7c3..7a45ed1ab38ea4 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -287,6 +287,64 @@ def test_mailbox(self): self.assertEqual(m._mailbox('Entwürfe'), '"Entwürfe"'.encode()) self.assertEqual(m._mailbox('Entw&APw-rfe'), b'Entw&APw-rfe') + def test_sequence_set(self): + m = imaplib.IMAP4.__new__(imaplib.IMAP4) + # A scalar is passed through as a string. + self.assertEqual(m._sequence_set(5), '5') + self.assertEqual(m._sequence_set('1:3,7'), '1:3,7') + # A sequence of numbers and ranges is formatted as a sequence set. + self.assertEqual(m._sequence_set([1, 2, 5]), '1,2,5') + self.assertEqual(m._sequence_set([1, (3, 5), (8, '*')]), '1,3:5,8:*') + self.assertEqual(m._sequence_set([(5, None)]), '5:*') + # A range is inclusive; a non-unit step falls back to explicit numbers. + self.assertEqual(m._sequence_set([range(1, 4), 7]), '1:3,7') + self.assertEqual(m._sequence_set([range(1, 10, 2)]), '1,3,5,7,9') + # Message numbers must be integers: a string is not coerced (the + # string form is the whole preformatted set), nor is a float. + self.assertRaises(TypeError, m._sequence_set, ['7']) + self.assertRaises(TypeError, m._sequence_set, [2.9]) + self.assertRaises(TypeError, m._sequence_set, [(1, 2.9)]) + + def test_set_quote(self): + m = imaplib.IMAP4.__new__(imaplib.IMAP4) + # A string is parenthesized unless it already is. + self.assertEqual(m._set_quote(r'\Seen'), r'(\Seen)') + self.assertEqual(m._set_quote(r'(\Seen)'), r'(\Seen)') + # A sequence of atoms is joined and parenthesized. + self.assertEqual(m._set_quote([r'\Seen', r'\Answered']), + r'(\Seen \Answered)') + self.assertEqual(m._set_quote(['MESSAGES', 'UNSEEN']), + '(MESSAGES UNSEEN)') + + def test_substitute(self): + sub = imaplib._substitute + # '?' quotes an astring; an atom-safe value is left unquoted. + self.assertEqual(sub('FROM ?', ['me@host']), 'FROM me@host') + self.assertEqual(sub('SUBJECT ?', ['hello world']), + 'SUBJECT "hello world"') + self.assertEqual(sub('SUBJECT ?', ['a"b']), 'SUBJECT "a\\"b"') + # An integer becomes a number, a list a parenthesized list. + self.assertEqual(sub('LARGER ?', [1000]), 'LARGER 1000') + self.assertEqual(sub('HEADER.FIELDS ?', [['DATE', 'FROM']]), + 'HEADER.FIELDS (DATE FROM)') + # '?f' emits flags verbatim, never quoted. + self.assertEqual(sub('?f', [r'\Seen']), r'\Seen') + self.assertEqual(sub('?f', [[r'\Seen', r'\Answered']]), + r'(\Seen \Answered)') + # '?s' formats a message sequence set. + self.assertEqual(sub('?s', [[1, (3, 5), (8, '*')]]), '1,3:5,8:*') + # '??' is a literal '?'. + self.assertEqual(sub('a?? b', []), 'a? b') + + def test_substitute_errors(self): + sub = imaplib._substitute + self.assertRaises(TypeError, sub, '? ?', ['x']) # too few parameters + self.assertRaises(TypeError, sub, '?', ['x', 'y']) # too many parameters + self.assertRaises(ValueError, sub, '?f', ['a b']) # not a valid flag + self.assertRaises(ValueError, sub, '?', ['a\r\nb']) # CR/LF not inline + self.assertRaises(TypeError, sub, '?', [True]) # bool is not a string + self.assertRaises(TypeError, sub, '?', [1.5]) # float is not a string + if ssl: class SecureTCPServer(socketserver.TCPServer): @@ -1342,6 +1400,11 @@ def test_copy(self): self.assertEqual(data, [b'COPY completed']) self.assertEqual(server.args, ['2:4', '"New folder"']) + # A structured message set is formatted into a sequence set. + typ, data = client.copy([2, (3, 5)], 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2,3:5', 'MEETING']) + def test_uid_copy(self): client, server = self._setup(make_simple_handler('UID', completed='UID COPY completed')) @@ -1363,6 +1426,11 @@ def test_uid_copy(self): self.assertEqual(data, [b'UID COPY completed']) self.assertEqual(server.args, ['COPY', '4827313:4828442', 'MEETING']) + # A structured message set is formatted into a sequence set. + typ, data = client.uid('copy', [1, (3, 5)], 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['COPY', '1,3:5', 'MEETING']) + def test_move(self): client, server = self._setup(make_simple_handler('MOVE')) client.login('user', 'pass') @@ -1377,6 +1445,11 @@ def test_move(self): self.assertEqual(data, [b'MOVE completed']) self.assertEqual(server.args, ['2:4', '"New folder"']) + # A structured message set is formatted into a sequence set. + typ, data = client.move([2, (3, 5)], 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2,3:5', 'MEETING']) + def test_uid_move(self): client, server = self._setup(make_simple_handler('UID', completed='UID MOVE completed')) @@ -1398,6 +1471,11 @@ def test_uid_move(self): self.assertEqual(data, [b'UID MOVE completed']) self.assertEqual(server.args, ['MOVE', '4827313:4828442', 'MEETING']) + # A structured message set is formatted into a sequence set. + typ, data = client.uid('move', [1, (3, 5)], 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['MOVE', '1,3:5', 'MEETING']) + def test_store(self): client, server = self._setup(make_simple_handler('STORE', [ r'* 2 FETCH (FLAGS (\Deleted \Seen))', @@ -1419,6 +1497,12 @@ def test_store(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['2:4', '+FLAGS', r'(\Deleted)']) + # The flags may be a sequence, and the message set may be structured. + typ, data = client.store([2, (3, 4)], '+FLAGS', + [r'\Deleted', r'\Seen']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2,3:4', '+FLAGS', r'(\Deleted \Seen)']) + def test_uid_store(self): client, server = self._setup(make_simple_handler('UID', [ r'* 23 FETCH (FLAGS (\Deleted \Seen) UID 4827313)', @@ -1451,6 +1535,13 @@ def test_uid_store(self): ]) self.assertEqual(server.args, ['STORE', '4827313:4828442', '+FLAGS', r'(\Deleted)']) + # The flags may be a sequence. + typ, data = client.uid('store', '4827313:4828442', '+FLAGS', + [r'\Deleted', r'\Seen']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['STORE', '4827313:4828442', '+FLAGS', r'(\Deleted \Seen)']) + def test_fetch(self): # The handler expands the requested sequence set and answers for # exactly those messages, so the test exercises the round trip of @@ -1508,6 +1599,23 @@ def cmd_FETCH(self, tag, args): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['2:4', 'fast']) + # A structured message set is formatted into a sequence set. + typ, data = client.fetch([2, (3, 4)], '(FLAGS)') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['2,3:4', '(FLAGS)']) + + # message_parts may be a sequence of items. + typ, data = client.fetch('1', ['UID', 'FLAGS']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['1', '(UID FLAGS)']) + + # 'params' substitutes and quotes '?' placeholders. + typ, data = client.fetch('1', 'FLAGS BODY[HEADER.FIELDS ?]', + params=[['DATE', 'FROM']]) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['1', '(FLAGS BODY[HEADER.FIELDS (DATE FROM)])']) + def test_uid_fetch(self): client, server = self._setup(make_simple_handler('UID', [ r'* 23 FETCH (FLAGS (\Seen) UID 4827313)', @@ -1543,6 +1651,18 @@ def test_uid_fetch(self): ]) self.assertEqual(server.args, ['FETCH', '4827313:4828442', '(FLAGS)']) + # message_parts may be a sequence, and 'params' substitutes '?'. + typ, data = client.uid('fetch', '4827313:4828442', ['UID', 'FLAGS']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['FETCH', '4827313:4828442', '(UID FLAGS)']) + + typ, data = client.uid('fetch', '4827313:4828442', + 'BODY[HEADER.FIELDS ?]', params=[['DATE', 'FROM']]) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['FETCH', '4827313:4828442', + '(BODY[HEADER.FIELDS (DATE FROM)])']) + def test_partial(self): client, server = self._setup(make_simple_handler('PARTIAL', ['* 1 FETCH (RFC822.TEXT<0.10> "0123456789")'])) @@ -1590,6 +1710,19 @@ def test_search(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['CHARSET', '"NF_Z_62-010_(1973)"', 'TEXT', 'XXXXXX']) + # 'params' substitutes and quotes '?' placeholders. + response[:] = ['* SEARCH 1'] + typ, data = client.search(None, 'FROM ? SUBJECT ?', + params=['me@host', 'trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['FROM', 'me@host', 'SUBJECT', '"trip report"']) + + # Without 'params', a literal '?' is sent unchanged. + typ, data = client.search(None, 'SUBJECT', '"what?"') + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['SUBJECT', '"what?"']) + def test_uid_search(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1633,6 +1766,14 @@ def test_uid_search(self): self.assertEqual(data, [b'43']) self.assertEqual(server.args, ['SEARCH', 'CHARSET', 'UTF-8', 'TEXT', 'XXXXXX']) + # 'params' substitutes and quotes '?' placeholders. + response[:] = ['* SEARCH 1'] + typ, data = client.uid('SEARCH', 'FROM ? SUBJECT ?', + params=['me@host', 'trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['SEARCH', 'FROM', 'me@host', 'SUBJECT', '"trip report"']) + def test_sort(self): response = [] client, server = self._setup(make_simple_handler('SORT', response)) @@ -1666,6 +1807,15 @@ def test_sort(self): self.assertEqual(typ, 'OK') self.assertIn('"Київ"'.encode('koi8-u'), server.line) + # sort_criteria may be a sequence, and 'params' substitutes and + # quotes '?' (a value with a space becomes a quoted string). + response[:] = ['* SORT 1'] + typ, data = client.sort(['REVERSE', 'DATE'], 'UTF-8', 'SUBJECT ?', + params=['trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['(REVERSE DATE)', 'UTF-8', 'SUBJECT', '"trip report"']) + def test_uid_sort(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1707,6 +1857,15 @@ def test_uid_sort(self): self.assertEqual(data, [br'2 84 882']) self.assertEqual(server.args, ['SORT', '(SUBJECT)', 'UTF-8', 'SINCE', '1-Feb-1994']) + # sort_criteria may be a sequence, and 'params' substitutes and + # quotes '?' (a value with a space becomes a quoted string). + response[:] = ['* SORT 1'] + typ, data = client.uid('sort', ['REVERSE', 'DATE'], 'UTF-8', 'SUBJECT ?', + params=['trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['SORT', '(REVERSE DATE)', 'UTF-8', 'SUBJECT', '"trip report"']) + def test_thread(self): response = [] client, server = self._setup(make_simple_handler('THREAD', response)) @@ -1754,6 +1913,15 @@ def test_thread(self): self.assertEqual(typ, 'OK') self.assertIn('"Київ"'.encode('koi8-u'), server.line) + # 'params' substitutes and quotes '?' (a value with a space becomes + # a quoted string). + response[:] = ['* THREAD (1)'] + typ, data = client.thread('REFERENCES', 'UTF-8', 'SUBJECT ?', + params=['trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['REFERENCES', 'UTF-8', 'SUBJECT', '"trip report"']) + def test_uid_thread(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1810,6 +1978,15 @@ def test_uid_thread(self): self.assertEqual(data, [b'(166)(167)(168)']) self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', 'UTF-8', 'SINCE', '5-MAR-2000']) + # 'params' substitutes and quotes '?' (a value with a space becomes + # a quoted string). + response[:] = ['* THREAD (1)'] + typ, data = client.uid('THREAD', 'REFERENCES', 'UTF-8', 'SUBJECT ?', + params=['trip report']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, + ['THREAD', 'REFERENCES', 'UTF-8', 'SUBJECT', '"trip report"']) + def test_delete(self): client, server = self._setup(make_simple_handler('DELETE')) client.login('user', 'pass') @@ -1900,6 +2077,11 @@ def test_status(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['"New folder"', '(UIDNEXT MESSAGES)']) + # The names argument may be a sequence of item names. + typ, data = client.status('blurdybloop', ['UIDNEXT', 'MESSAGES']) + self.assertEqual(typ, 'OK') + self.assertEqual(server.args, ['blurdybloop', '(UIDNEXT MESSAGES)']) + def test_getacl(self): client, server = self._setup(make_simple_handler('GETACL', ['* ACL INBOX Fred rwipslxetad'])) diff --git a/Misc/NEWS.d/next/Library/2026-07-06-14-30-00.gh-issue-153521.iMaP47.rst b/Misc/NEWS.d/next/Library/2026-07-06-14-30-00.gh-issue-153521.iMaP47.rst new file mode 100644 index 00000000000000..93edb1532d5d41 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-06-14-30-00.gh-issue-153521.iMaP47.rst @@ -0,0 +1,7 @@ +Add support for structured arguments in :mod:`imaplib` command methods. A +*message_set* and lists of flags or other atoms can now be passed as +sequences instead of preformatted strings, and the +:meth:`~imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.fetch`, +:meth:`~imaplib.IMAP4.sort`, :meth:`~imaplib.IMAP4.thread` and +:meth:`~imaplib.IMAP4.uid` methods accept a *params* keyword argument that +substitutes and quotes ``?`` placeholders.