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.