From 2eab1cb1295daddd1f2e097d8af549c49fbe414e Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 10 Jul 2026 13:26:07 +0300 Subject: [PATCH] gh-153502: Add uid parameter to imaplib command methods Add a keyword-only uid=False argument to IMAP4.copy(), move(), fetch(), store(), search(), sort(), thread() and expunge(), selecting the UID variant of the command as a shorthand for uid(). expunge() also gains an optional message_set argument, required for UID EXPUNGE. Co-Authored-By: Claude Opus 4.8 --- Doc/library/imaplib.rst | 62 +++++++++-- Doc/whatsnew/3.16.rst | 9 ++ Lib/imaplib.py | 100 +++++++++++++----- Lib/test/test_imaplib.py | 82 ++++++++++++++ ...-07-10-18-30-00.gh-issue-153502.uIdKw0.rst | 6 ++ 5 files changed, 227 insertions(+), 32 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-10-18-30-00.gh-issue-153502.uIdKw0.rst diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 6872ba0d6cbfcb..3f26243f77dd5a 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -273,10 +273,16 @@ An :class:`IMAP4` instance has the following methods: mailbox. This is the recommended command before ``LOGOUT``. -.. method:: IMAP4.copy(message_set, new_mailbox) +.. method:: IMAP4.copy(message_set, new_mailbox, *, uid=False) Copy *message_set* messages onto end of *new_mailbox*. + If *uid* is true, *message_set* is a set of UIDs and the ``UID COPY`` + command is used instead of ``COPY``. + + .. versionchanged:: next + Added the *uid* parameter. + .. method:: IMAP4.create(mailbox) @@ -303,19 +309,33 @@ An :class:`IMAP4` instance has the following methods: The :meth:`enable` method itself, and :RFC:`6855` support. -.. method:: IMAP4.expunge() +.. method:: IMAP4.expunge(message_set=None, *, uid=False) Permanently remove deleted items from selected mailbox. Generates an ``EXPUNGE`` response for each deleted message. Returned data contains a list of ``EXPUNGE`` message numbers in order received. + If *uid* is true, the ``UID EXPUNGE`` command (:rfc:`4315`) is used to remove + only the messages that both are marked as deleted and have a UID in + *message_set*. *message_set* is required in this case, and must be omitted + otherwise. + + .. versionchanged:: next + Added the *message_set* and *uid* parameters. + -.. method:: IMAP4.fetch(message_set, message_parts) +.. method:: IMAP4.fetch(message_set, message_parts, *, uid=False) Fetch (parts of) messages. *message_parts* should be a string of message part names enclosed within parentheses, eg: ``"(UID BODY[TEXT])"``. Returned 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``). + + .. versionchanged:: next + Added the *uid* parameter. + .. method:: IMAP4.getacl(mailbox) @@ -495,12 +515,15 @@ An :class:`IMAP4` instance has the following methods: Returned data are tuples of message part envelope and data. -.. method:: IMAP4.move(message_set, new_mailbox) +.. method:: IMAP4.move(message_set, new_mailbox, *, uid=False) Move *message_set* messages onto end of *new_mailbox*. The server must support the ``MOVE`` capability (:rfc:`6851`). + If *uid* is true, *message_set* is a set of UIDs and the ``UID MOVE`` + command is used instead of ``MOVE``. + .. versionadded:: next @@ -575,7 +598,7 @@ An :class:`IMAP4` instance has the following methods: code, instead of the usual type. -.. method:: IMAP4.search(charset, criterion[, ...]) +.. method:: IMAP4.search(charset, criterion[, ...], *, uid=False) 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 @@ -584,6 +607,9 @@ An :class:`IMAP4` instance has the following methods: the ``UTF8=ACCEPT`` capability was enabled using the :meth:`enable` command. + If *uid* is true, the message numbers in the response are UIDs + (``UID SEARCH``). + Example:: # M is a connected IMAP4 instance... @@ -592,6 +618,9 @@ An :class:`IMAP4` instance has the following methods: # or: typ, msgnums = M.search(None, '(FROM "LDJ")') + .. versionchanged:: next + Added the *uid* parameter. + .. method:: IMAP4.select(mailbox='INBOX', readonly=False) @@ -636,7 +665,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[, ...]) +.. method:: IMAP4.sort(sort_criteria, charset, search_criterion[, ...], *, uid=False) The ``sort`` command is a variant of ``search`` with sorting semantics for the results. Returned data contains a space separated list of matching message @@ -651,8 +680,13 @@ An :class:`IMAP4` instance has the following methods: the interpretation of strings in the searching criteria. It then returns the numbers of matching messages. + If *uid* is true, the message numbers in the response are UIDs (``UID SORT``). + This is an ``IMAP4rev1`` extension command. + .. versionchanged:: next + Added the *uid* parameter. + .. method:: IMAP4.starttls(ssl_context=None) @@ -681,12 +715,15 @@ An :class:`IMAP4` instance has the following methods: Request named status conditions for *mailbox*. -.. method:: IMAP4.store(message_set, command, flag_list) +.. method:: IMAP4.store(message_set, command, flag_list, *, uid=False) Alters flag dispositions for messages in mailbox. *command* is specified by section 6.4.6 of :rfc:`3501` as being one of "FLAGS", "+FLAGS", or "-FLAGS", optionally with a suffix of ".SILENT". + If *uid* is true, *message_set* is a set of UIDs and the ``UID STORE`` + command is used instead of ``STORE``. + For example, to set the delete flag on all messages:: typ, data = M.search(None, 'ALL') @@ -706,12 +743,15 @@ An :class:`IMAP4` instance has the following methods: Python 3.6, handles them if they are sent from the server, since this improves real-world compatibility. + .. versionchanged:: next + Added the *uid* parameter. + .. method:: IMAP4.subscribe(mailbox) Subscribe to new mailbox. -.. method:: IMAP4.thread(threading_algorithm, charset, search_criterion[, ...]) +.. method:: IMAP4.thread(threading_algorithm, charset, search_criterion[, ...], *, uid=False) The ``thread`` command is a variant of ``search`` with threading semantics for the results. Returned data contains a space separated list of thread members. @@ -729,8 +769,14 @@ An :class:`IMAP4` instance has the following methods: returns the matching messages threaded according to the specified threading algorithm. + If *uid* is true, the message numbers in the response are UIDs + (``UID THREAD``). + This is an ``IMAP4rev1`` extension command. + .. versionchanged:: next + Added the *uid* parameter. + .. method:: IMAP4.uid(command, arg[, ...]) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index c3653523647170..5e8abc7034bea0 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -267,6 +267,15 @@ imaplib as ordinary :class:`str`. (Contributed by Serhiy Storchaka in :gh:`49555`.) +* The :meth:`~imaplib.IMAP4.copy`, :meth:`~imaplib.IMAP4.move`, + :meth:`~imaplib.IMAP4.fetch`, :meth:`~imaplib.IMAP4.store`, + :meth:`~imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.sort`, + :meth:`~imaplib.IMAP4.thread` and :meth:`~imaplib.IMAP4.expunge` methods + now accept a keyword-only *uid* argument that selects the corresponding + ``UID`` command, as a more convenient alternative to + :meth:`~imaplib.IMAP4.uid`. + (Contributed by Serhiy Storchaka in :gh:`153502`.) + ipaddress --------- diff --git a/Lib/imaplib.py b/Lib/imaplib.py index d8acf085e7a128..4e7619b61e89cd 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -590,13 +590,17 @@ def close(self): return typ, dat - def copy(self, message_set, new_mailbox): + def copy(self, message_set, new_mailbox, *, uid=False): """Copy 'message_set' messages onto end of 'new_mailbox'. (typ, [data]) = .copy(message_set, new_mailbox) + + If 'uid' is true, 'message_set' is a set of UIDs (UID COPY). """ - return self._simple_command('COPY', self._sequence_set(message_set), - self._mailbox(new_mailbox)) + args = (self._sequence_set(message_set), self._mailbox(new_mailbox)) + if uid: + return self._simple_command('UID', self._atom('COPY'), *args) + return self._simple_command('COPY', *args) def create(self, mailbox): @@ -634,7 +638,7 @@ def enable(self, capability): self._mode_utf8() return typ, data - def expunge(self): + def expunge(self, message_set=None, *, uid=False): """Permanently remove deleted items from selected mailbox. Generates 'EXPUNGE' response for each deleted message. @@ -642,13 +646,26 @@ def expunge(self): (typ, [data]) = .expunge() 'data' is list of 'EXPUNGE'd message numbers in order received. + + If 'uid' is true, only messages with a UID in 'message_set' are + removed (UID EXPUNGE, RFC 4315); 'message_set' is then required and + must be omitted otherwise. """ name = 'EXPUNGE' - typ, dat = self._simple_command(name) + if uid: + if message_set is None: + raise self.error('UID EXPUNGE requires a message set') + typ, dat = self._simple_command('UID', self._atom(name), + self._sequence_set(message_set)) + else: + if message_set is not None: + raise self.error('EXPUNGE takes no message set; ' + 'use uid=True for UID EXPUNGE') + typ, dat = self._simple_command(name) return self._untagged_response(typ, dat, name) - def fetch(self, message_set, message_parts): + def fetch(self, message_set, message_parts, *, uid=False): """Fetch (parts of) messages. (typ, [data, ...]) = .fetch(message_set, message_parts) @@ -657,10 +674,17 @@ def fetch(self, message_set, message_parts): enclosed in parentheses, eg: "(UID BODY[TEXT])". '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' - typ, dat = self._simple_command(name, self._sequence_set(message_set), - self._fetch_parts(message_parts)) + args = (self._sequence_set(message_set), + self._fetch_parts(message_parts)) + if uid: + typ, dat = self._simple_command('UID', self._atom(name), *args) + else: + typ, dat = self._simple_command(name, *args) return self._untagged_response(typ, dat, name) @@ -842,13 +866,17 @@ def lsub(self, directory='', pattern='*'): self._list_mailbox(pattern)) return self._untagged_response(typ, dat, name) - def move(self, message_set, new_mailbox): + def move(self, message_set, new_mailbox, *, uid=False): """Move 'message_set' messages onto end of 'new_mailbox'. (typ, [data]) = .move(message_set, new_mailbox) + + If 'uid' is true, 'message_set' is a set of UIDs (UID MOVE). """ - return self._simple_command('MOVE', self._sequence_set(message_set), - self._mailbox(new_mailbox)) + args = (self._sequence_set(message_set), self._mailbox(new_mailbox)) + if uid: + return self._simple_command('UID', self._atom('MOVE'), *args) + return self._simple_command('MOVE', *args) def myrights(self, mailbox): """Show my ACLs for a mailbox (i.e. the rights that I have on mailbox). @@ -913,22 +941,27 @@ def rename(self, oldmailbox, newmailbox): self._mailbox(newmailbox)) - def search(self, charset, *criteria): + def search(self, charset, *criteria, uid=False): """Search mailbox for matching messages. (typ, [data]) = .search(charset, criterion, ...) 'data' is space separated list of matching message numbers. If UTF8 is enabled, charset MUST be None. + If 'uid' is true, the message numbers in the response are UIDs + (UID SEARCH). """ name = 'SEARCH' if charset is not None: if self.utf8_enabled: raise IMAP4.error("Non-None charset not valid in UTF8 mode") - typ, dat = self._simple_command(name, - 'CHARSET', self._astring(charset), *criteria) + args = ('CHARSET', self._astring(charset), *criteria) else: - typ, dat = self._simple_command(name, *criteria) + args = criteria + if uid: + typ, dat = self._simple_command('UID', self._atom(name), *args) + else: + typ, dat = self._simple_command(name, *args) return self._untagged_response(typ, dat, name) @@ -991,10 +1024,13 @@ def setquota(self, root, limits): return self._untagged_response(typ, dat, 'QUOTA') - def sort(self, sort_criteria, charset, *search_criteria): + def sort(self, sort_criteria, charset, *search_criteria, uid=False): """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). """ name = 'SORT' #if not name in self.capabilities: # Let the server decide! @@ -1002,7 +1038,11 @@ def sort(self, sort_criteria, charset, *search_criteria): sort_criteria = self._set_quote(sort_criteria) if charset is not None: charset = self._astring(charset) - typ, dat = self._simple_command(name, sort_criteria, charset, *search_criteria) + args = (sort_criteria, charset, *search_criteria) + if uid: + typ, dat = self._simple_command('UID', self._atom(name), *args) + else: + typ, dat = self._simple_command(name, *args) return self._untagged_response(typ, dat, name) @@ -1043,14 +1083,20 @@ def status(self, mailbox, names): return self._untagged_response(typ, dat, name) - def store(self, message_set, command, flags): + def store(self, message_set, command, flags, *, uid=False): """Alters flag dispositions for messages in mailbox. (typ, [data]) = .store(message_set, command, flags) + + If 'uid' is true, 'message_set' is a set of UIDs (UID STORE). """ - flags = self._set_quote(flags) - typ, dat = self._simple_command('STORE', self._sequence_set(message_set), - command, flags) + name = 'STORE' + args = (self._sequence_set(message_set), command, + self._set_quote(flags)) + if uid: + typ, dat = self._simple_command('UID', self._atom(name), *args) + else: + typ, dat = self._simple_command(name, *args) return self._untagged_response(typ, dat, 'FETCH') @@ -1062,16 +1108,22 @@ def subscribe(self, mailbox): return self._simple_command('SUBSCRIBE', self._mailbox(mailbox)) - def thread(self, threading_algorithm, charset, *search_criteria): + def thread(self, threading_algorithm, charset, *search_criteria, uid=False): """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). """ name = 'THREAD' if charset is not None: charset = self._astring(charset) - typ, dat = self._simple_command(name, self._atom(threading_algorithm), - charset, *search_criteria) + args = (self._atom(threading_algorithm), charset, *search_criteria) + if uid: + typ, dat = self._simple_command('UID', self._atom(name), *args) + else: + typ, dat = self._simple_command(name, *args) return self._untagged_response(typ, dat, name) diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 16a96381992b68..2413a8728ec72b 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -1201,6 +1201,25 @@ def test_expunge(self): self.assertEqual(typ, 'OK') self.assertEqual(data, [b'3', b'3', b'5', b'8']) + # A message set is only accepted with uid=True (UID EXPUNGE). + with self.assertRaises(imaplib.IMAP4.error): + client.expunge('3:8') + + def test_uid_expunge(self): + client, server = self._setup(make_simple_handler('UID', + ['* 3 EXPUNGE', '* 3 EXPUNGE', '* 5 EXPUNGE', '* 8 EXPUNGE'], + 'UID EXPUNGE completed')) + client.login('user', 'pass') + client.select() + typ, data = client.expunge('3:8', uid=True) + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'3', b'3', b'5', b'8']) + self.assertEqual(server.args, ['EXPUNGE', '3:8']) + + # UID EXPUNGE requires a message set. + with self.assertRaises(imaplib.IMAP4.error): + client.expunge(uid=True) + def test_close(self): client, server = self._setup(make_simple_handler('CLOSE')) client.login('user', 'pass') @@ -1308,6 +1327,12 @@ def test_uid_copy(self): self.assertEqual(data, [None]) self.assertEqual(server.args, ['COPY', '4827313:4828442', '"New folder"']) + # The uid=True keyword is a shorthand for uid('COPY', ...). + typ, data = client.copy('4827313:4828442', 'MEETING', uid=True) + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'UID COPY completed']) + self.assertEqual(server.args, ['COPY', '4827313:4828442', 'MEETING']) + def test_move(self): client, server = self._setup(make_simple_handler('MOVE')) client.login('user', 'pass') @@ -1337,6 +1362,12 @@ def test_uid_move(self): self.assertEqual(data, [None]) self.assertEqual(server.args, ['MOVE', '4827313:4828442', '"New folder"']) + # The uid=True keyword is a shorthand for uid('MOVE', ...). + typ, data = client.move('4827313:4828442', 'MEETING', uid=True) + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'UID MOVE completed']) + self.assertEqual(server.args, ['MOVE', '4827313:4828442', 'MEETING']) + def test_store(self): client, server = self._setup(make_simple_handler('STORE', [ r'* 2 FETCH (FLAGS (\Deleted \Seen))', @@ -1379,6 +1410,17 @@ def test_uid_store(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['STORE', '4827313:4828442', '+FLAGS', r'(\Deleted)']) + # The uid=True keyword is a shorthand for uid('STORE', ...). + typ, data = client.store('4827313:4828442', '+FLAGS', r'(\Deleted)', + uid=True) + self.assertEqual(typ, 'OK') + self.assertEqual(data, [ + br'23 (FLAGS (\Deleted \Seen) UID 4827313)', + br'24 (FLAGS (\Deleted) UID 4827943)', + br'25 (FLAGS (\Deleted \Flagged \Seen) UID 4828442)', + ]) + self.assertEqual(server.args, ['STORE', '4827313:4828442', '+FLAGS', r'(\Deleted)']) + 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 @@ -1461,6 +1503,16 @@ def test_uid_fetch(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['FETCH', '4827313:4828442', 'ALL']) + # The uid=True keyword is a shorthand for uid('FETCH', ...). + typ, data = client.fetch('4827313:4828442', '(FLAGS)', uid=True) + self.assertEqual(typ, 'OK') + self.assertEqual(data, [ + br'23 (FLAGS (\Seen) UID 4827313)', + br'24 (FLAGS (\Seen) UID 4827943)', + br'25 (FLAGS (\Seen) UID 4828442)', + ]) + self.assertEqual(server.args, ['FETCH', '4827313:4828442', '(FLAGS)']) + def test_partial(self): client, server = self._setup(make_simple_handler('PARTIAL', ['* 1 FETCH (RFC822.TEXT<0.10> "0123456789")'])) @@ -1526,6 +1578,21 @@ def test_uid_search(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['SEARCH', 'CHARSET', '"NF_Z_62-010_(1973)"', 'TEXT', 'XXXXXX']) + # The uid=True keyword is a shorthand for uid('SEARCH', ...). + response[:] = ['* SEARCH 2 84 882'] + typ, data = client.search(None, 'FLAGGED', 'SINCE', '1-Feb-1994', + 'NOT', 'FROM', '"Smith"', uid=True) + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'2 84 882']) + self.assertEqual(server.args, + ['SEARCH', 'FLAGGED', 'SINCE', '1-Feb-1994', 'NOT', 'FROM', '"Smith"']) + + response[:] = ['* SEARCH 43'] + typ, data = client.search('UTF-8', 'TEXT', 'XXXXXX', uid=True) + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'43']) + self.assertEqual(server.args, ['SEARCH', 'CHARSET', 'UTF-8', 'TEXT', 'XXXXXX']) + def test_sort(self): response = [] client, server = self._setup(make_simple_handler('SORT', response)) @@ -1581,6 +1648,13 @@ def test_uid_sort(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['SORT', '(SUBJECT)', '"NF_Z_62-010_(1973)"', 'TEXT', '"not in mailbox"']) + # The uid=True keyword is a shorthand for uid('SORT', ...). + response[:] = ['* SORT 2 84 882'] + typ, data = client.sort('(SUBJECT)', 'UTF-8', 'SINCE', '1-Feb-1994', uid=True) + self.assertEqual(typ, 'OK') + self.assertEqual(data, [br'2 84 882']) + self.assertEqual(server.args, ['SORT', '(SUBJECT)', 'UTF-8', 'SINCE', '1-Feb-1994']) + def test_thread(self): response = [] client, server = self._setup(make_simple_handler('THREAD', response)) @@ -1664,6 +1738,14 @@ def test_uid_thread(self): self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', '"NF_Z_62-010_(1973)"', 'TEXT', '"gewp"']) + # The uid=True keyword is a shorthand for uid('THREAD', ...). + response[:] = ['* THREAD (166)(167)(168)'] + typ, data = client.thread('ORDEREDSUBJECT', 'UTF-8', 'SINCE', '5-MAR-2000', + uid=True) + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'(166)(167)(168)']) + self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', 'UTF-8', 'SINCE', '5-MAR-2000']) + def test_delete(self): client, server = self._setup(make_simple_handler('DELETE')) client.login('user', 'pass') diff --git a/Misc/NEWS.d/next/Library/2026-07-10-18-30-00.gh-issue-153502.uIdKw0.rst b/Misc/NEWS.d/next/Library/2026-07-10-18-30-00.gh-issue-153502.uIdKw0.rst new file mode 100644 index 00000000000000..7717c8487cf643 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-10-18-30-00.gh-issue-153502.uIdKw0.rst @@ -0,0 +1,6 @@ +:meth:`imaplib.IMAP4.copy`, :meth:`~imaplib.IMAP4.move`, +:meth:`~imaplib.IMAP4.fetch`, :meth:`~imaplib.IMAP4.store`, +:meth:`~imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.sort`, +:meth:`~imaplib.IMAP4.thread` and :meth:`~imaplib.IMAP4.expunge` now accept a +keyword-only *uid* argument that selects the corresponding ``UID`` command, as +a more convenient alternative to :meth:`~imaplib.IMAP4.uid`.