From 7b1a85625f28a5c169ba22f286ca20c8d0f61b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Sat, 5 May 2018 09:01:26 +0200 Subject: [PATCH 1/5] Add IMAP4.move_messages() method. --- Lib/imaplib.py | 50 ++++++++++++++++++ Lib/test/test_imaplib.py | 51 +++++++++++++++++++ .../2020-03-19-11-39-00.bpo-33327.taAn2e.rst | 4 ++ 3 files changed, 105 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2020-03-19-11-39-00.bpo-33327.taAn2e.rst diff --git a/Lib/imaplib.py b/Lib/imaplib.py index fa4c0f8f62361a1..0ff58a4127c41d3 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -23,6 +23,7 @@ __version__ = "2.58" import binascii, errno, random, re, socket, subprocess, sys, time, calendar +from collections import namedtuple from datetime import datetime, timezone, timedelta from io import DEFAULT_BUFFER_SIZE @@ -131,6 +132,10 @@ _Untagged_status = br'\* (?P\d+) (?P[A-Z-]+)( (?P.*))?' +# Store capabilities present in the current server +# Add additional ones as needed +AvailableCapabilities = namedtuple('Capas', ['MOVE', 'UIDPLUS']) + class IMAP4: @@ -194,6 +199,7 @@ def __init__(self, host='', port=IMAP4_PORT, timeout=None): self.continuation_response = '' # Last continuation response self.is_readonly = False # READ-ONLY desired state self.tagnum = 0 + self._features_available = AvailableCapabilities(False, False) self._tls_established = False self._mode_ascii() @@ -611,6 +617,14 @@ def login(self, user, password): if typ != 'OK': raise self.error(dat[-1]) self.state = 'AUTH' + + ok, dat = self.capability() + if typ != 'OK': + raise self.error(dat[-1]) + capas = dat[0].decode() + self._features_available = AvailableCapabilities._make( + ['MOVE' in capas, 'UIDPLUS' in capas]) + return typ, dat @@ -655,6 +669,40 @@ def lsub(self, directory='""', pattern='*'): typ, dat = self._simple_command(name, directory, pattern) return self._untagged_response(typ, dat, name) + def move_messages(self, target: str, messages: str) -> None: + """ + Higher level command to move message inside of one account. + + :param target: name of the folder to move messages into + :param messages: UID sequence set (according to + https://tools.ietf.org/html/rfc3501#section-9) + """ + if self._features_available.MOVE: + typ, dat = self._simple_command('UID', 'MOVE', + messages, target) + ok, data = self._untagged_response(typ, dat, 'MOVE') + if ok != 'OK': + raise IOError('Cannot move messages to folder %s' % target) + elif self._features_available.UIDPLUS: + ok, data = self.uid('COPY', '%s %s' % (messages, target)) + if ok != 'OK': + raise IOError('Cannot copy messages to folder %s' % target) + ok, data = self.uid('STORE', + r'+FLAGS.SILENT (\DELETED) %s' % messages) + if ok != 'OK': + raise IOError('Cannot delete messages.') + ok, data = self.uid('EXPUNGE', messages) + if ok != 'OK': + raise IOError('Cannot expunge messages.') + else: + ok, data = self.uid('COPY', '%s %s' % (messages, target)) + if ok != 'OK': + raise IOError('Cannot copy messages to folder %s' % target) + ok, data = self.uid('STORE', + r'+FLAGS.SILENT (\DELETED) %s' % messages) + if ok != 'OK': + raise IOError('Cannot delete messages.') + def myrights(self, mailbox): """Show my ACLs for a mailbox (i.e. the rights that I have on mailbox). @@ -1063,6 +1111,8 @@ def _get_capabilities(self): dat = str(dat[-1], self._encoding) dat = dat.upper() self.capabilities = tuple(dat.split()) + self._features_available = AvailableCapabilities._make( + ['MOVE' in dat, 'UIDPLUS' in dat]) def _get_response(self): diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index c2b935f58164e5a..12f75914facce30 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -538,6 +538,57 @@ def test_unselect(self): self.assertEqual(data[0], b'Returned to authenticated state. (Success)') self.assertEqual(client.state, 'AUTH') + def test_move_messages(self): + from email.message import EmailMessage + + class MoveServer(SimpleIMAPHandler): + capabilities = 'ENABLE UTF8=ACCEPT' + + def cmd_ENABLE(self, tag, args): + self._send_tagged(tag, 'OK', 'ENABLE successful') + + def cmd_AUTHENTICATE(self, tag, args): + self._send_textline('+') + self.server.response = yield + self._send_tagged(tag, 'OK', 'FAKEAUTH successful') + + def cmd_APPEND(self, tag, args): + self._send_textline('+') + self.server.response = yield + self._send_tagged(tag, 'OK', 'okay') + + def cmd_CREATE(self, tag, args): + print(f'args = {args}') + self._send_textline('* CREATE ' + args[0]) + self._send_tagged(tag, 'OK', 'okay') + + client, _ = self._setup(MoveServer) + typ, data = client.login('user', 'pass') + self.assertEqual(len(client._features_available), 2) + typ, data = client.create('source') + self.assertEqual(typ, 'OK') + typ, data = client.create('target') + self.assertEqual(typ, 'OK') + + msg = EmailMessage() + msg.set_content('This is a testing message') + msg['Subject'] = 'Test message' + msg['From'] = 'test@example.com' + msg['To'] = 'testee@example.com' + # typ, data = client.append('source', None, None, msg.as_bytes()) + # self.assertEqual(typ, 'OK') + # typ, data = client.select('source') + # self.assertEqual(typ, 'OK') + # self.assertEqual(int(data[0]), 1) + # typ, data = client.search(None, 'source') + # self.assertEqual(typ, 'OK') + # msg_id = + # typ, data = client.move_messages() + # self.assertEqual(typ, 'OK') + # typ, data = client.search(None, 'target') + # self.assertEqual(typ, 'OK') + # self.assertEqual(int(data[0]), 1) + class NewIMAPTests(NewIMAPTestsMixin, unittest.TestCase): imap_class = imaplib.IMAP4 diff --git a/Misc/NEWS.d/next/Library/2020-03-19-11-39-00.bpo-33327.taAn2e.rst b/Misc/NEWS.d/next/Library/2020-03-19-11-39-00.bpo-33327.taAn2e.rst new file mode 100644 index 000000000000000..c4f550d9aacd4da --- /dev/null +++ b/Misc/NEWS.d/next/Library/2020-03-19-11-39-00.bpo-33327.taAn2e.rst @@ -0,0 +1,4 @@ +Provides method :method:`.move_messages()` of IMAP4 object, which moves +messages among two IMAP folders. This change introduces and uses caching of +the server’s capabilities in :attribute:`__features_available` internal +attribute. From 09487ab545eaae619bc51d5fae9d5a338a8d1def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Thu, 2 Jul 2020 18:06:07 +0200 Subject: [PATCH 2/5] Fix typo --- Lib/imaplib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 0ff58a4127c41d3..aac774ffba39777 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -618,7 +618,7 @@ def login(self, user, password): raise self.error(dat[-1]) self.state = 'AUTH' - ok, dat = self.capability() + typ, dat = self.capability() if typ != 'OK': raise self.error(dat[-1]) capas = dat[0].decode() From 893f3594e166911cf81fa5c035c486b504de6ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Wed, 30 Dec 2020 23:50:57 +0100 Subject: [PATCH 3/5] Fix documentation --- .../next/Library/2020-03-19-11-39-00.bpo-33327.taAn2e.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2020-03-19-11-39-00.bpo-33327.taAn2e.rst b/Misc/NEWS.d/next/Library/2020-03-19-11-39-00.bpo-33327.taAn2e.rst index c4f550d9aacd4da..ecafca874d89719 100644 --- a/Misc/NEWS.d/next/Library/2020-03-19-11-39-00.bpo-33327.taAn2e.rst +++ b/Misc/NEWS.d/next/Library/2020-03-19-11-39-00.bpo-33327.taAn2e.rst @@ -1,4 +1,4 @@ -Provides method :method:`.move_messages()` of IMAP4 object, which moves -messages among two IMAP folders. This change introduces and uses caching of -the server’s capabilities in :attribute:`__features_available` internal -attribute. +Provides method :meth:`.move_messages()` of IMAP4 object, which +moves messages among two IMAP folders. This change introduces and +uses caching of the server’s capabilities in +:attr:`__features_available` internal attribute. From 0db63b3498ff64ec719c8e50e30830690efa959b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Sat, 2 Jan 2021 15:33:47 +0100 Subject: [PATCH 4/5] Use method _get_capabilities() --- Lib/imaplib.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Lib/imaplib.py b/Lib/imaplib.py index aac774ffba39777..ff38b4d7343000f 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -618,12 +618,7 @@ def login(self, user, password): raise self.error(dat[-1]) self.state = 'AUTH' - typ, dat = self.capability() - if typ != 'OK': - raise self.error(dat[-1]) - capas = dat[0].decode() - self._features_available = AvailableCapabilities._make( - ['MOVE' in capas, 'UIDPLUS' in capas]) + self._get_capabilities() return typ, dat @@ -1106,6 +1101,8 @@ def _command_complete(self, name, tag): def _get_capabilities(self): typ, dat = self.capability() + if typ != 'OK': + raise self.error(dat[-1]) if dat == [None]: raise self.error('no CAPABILITY response from server') dat = str(dat[-1], self._encoding) From b13c1ea7d55e768a77753a3f26e46342820d26a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Sun, 3 Jan 2021 00:13:26 +0100 Subject: [PATCH 5/5] Improved tests --- Lib/imaplib.py | 39 ++++++++++++++++++++++----------------- Lib/test/test_imaplib.py | 38 ++++++++++++++++++++------------------ 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/Lib/imaplib.py b/Lib/imaplib.py index ff38b4d7343000f..cbeaf381e99f0c7 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -26,6 +26,7 @@ from collections import namedtuple from datetime import datetime, timezone, timedelta from io import DEFAULT_BUFFER_SIZE +from typing import Tuple try: import ssl @@ -664,39 +665,43 @@ def lsub(self, directory='""', pattern='*'): typ, dat = self._simple_command(name, directory, pattern) return self._untagged_response(typ, dat, name) - def move_messages(self, target: str, messages: str) -> None: + def move_messages(self, target: str, msgs: str) -> Tuple[str, str]: """ Higher level command to move message inside of one account. + Following RFC-6851. - :param target: name of the folder to move messages into - :param messages: UID sequence set (according to + :param str target: name of the folder to move messages into + :param str messages: UID sequence set (according to https://tools.ietf.org/html/rfc3501#section-9) + :return: type, data tuple; type is 'OK' or something else in + case of failure. + :rtype: tuple [str, str] """ if self._features_available.MOVE: typ, dat = self._simple_command('UID', 'MOVE', - messages, target) - ok, data = self._untagged_response(typ, dat, 'MOVE') - if ok != 'OK': - raise IOError('Cannot move messages to folder %s' % target) + msgs, target) + return self._untagged_response(typ, dat, 'MOVE') elif self._features_available.UIDPLUS: - ok, data = self.uid('COPY', '%s %s' % (messages, target)) + ok, data = self.uid('COPY', f'{msgs} {target}') if ok != 'OK': - raise IOError('Cannot copy messages to folder %s' % target) + return ok, f'Cannot copy messages {msgs} to folder {target}' ok, data = self.uid('STORE', - r'+FLAGS.SILENT (\DELETED) %s' % messages) + f'+FLAGS.SILENT (\\DELETED) {msgs}') if ok != 'OK': - raise IOError('Cannot delete messages.') - ok, data = self.uid('EXPUNGE', messages) + return ok, f'Cannot delete messages {msgs}.' + ok, data = self.uid('EXPUNGE', msgs) if ok != 'OK': - raise IOError('Cannot expunge messages.') + return ok, f'Cannot expunge messages {msgs}.' + return ok, f'Messages {msgs} moved to {target}.' else: - ok, data = self.uid('COPY', '%s %s' % (messages, target)) + ok, data = self.uid('COPY', f'{msgs} {target}') if ok != 'OK': - raise IOError('Cannot copy messages to folder %s' % target) + return ok, f'Cannot copy messages {msgs} to folder {target}' ok, data = self.uid('STORE', - r'+FLAGS.SILENT (\DELETED) %s' % messages) + f'+FLAGS.SILENT (\\DELETED) {msgs}') if ok != 'OK': - raise IOError('Cannot delete messages.') + return ok, f'Cannot delete messages {msgs}.' + return ok, f'Messages {msgs} moved to {target}.' def myrights(self, mailbox): """Show my ACLs for a mailbox (i.e. the rights that I have on mailbox). diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 12f75914facce30..b2b40893ba5c148 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -558,36 +558,38 @@ def cmd_APPEND(self, tag, args): self._send_tagged(tag, 'OK', 'okay') def cmd_CREATE(self, tag, args): - print(f'args = {args}') self._send_textline('* CREATE ' + args[0]) self._send_tagged(tag, 'OK', 'okay') + def cmd_MOVE(self, tag, args): + self._send_textline('* MOVE ' + args[0]) + self._send_tagged(tag, 'OK', 'okay') + + def cmd_SELECT(self, tag, args): + self.server.is_selected = True + self._send_textline('* 2 EXISTS') + self._send_tagged(tag, 'OK', '[READ-WRITE] SELECT completed.') + client, _ = self._setup(MoveServer) typ, data = client.login('user', 'pass') - self.assertEqual(len(client._features_available), 2) typ, data = client.create('source') - self.assertEqual(typ, 'OK') typ, data = client.create('target') - self.assertEqual(typ, 'OK') + typ, data = client.select('source') msg = EmailMessage() - msg.set_content('This is a testing message') msg['Subject'] = 'Test message' msg['From'] = 'test@example.com' msg['To'] = 'testee@example.com' - # typ, data = client.append('source', None, None, msg.as_bytes()) - # self.assertEqual(typ, 'OK') - # typ, data = client.select('source') - # self.assertEqual(typ, 'OK') - # self.assertEqual(int(data[0]), 1) - # typ, data = client.search(None, 'source') - # self.assertEqual(typ, 'OK') - # msg_id = - # typ, data = client.move_messages() - # self.assertEqual(typ, 'OK') - # typ, data = client.search(None, 'target') - # self.assertEqual(typ, 'OK') - # self.assertEqual(int(data[0]), 1) + msg.set_content('This is a testing message') + print(f'=========================\nmsg:\n{msg}=============') + typ, data = client.append('source', None, None, msg.as_bytes()) + + typ, data = client.search(None, 'ALL') + typ, data = client.move_messages('target', data[0]) + self.assertEqual(typ, 'OK') + typ, data = client.search(None, 'target', 'ALL') + self.assertEqual(typ, 'OK') + self.assertEqual(int(data[0]), 1) class NewIMAPTests(NewIMAPTestsMixin, unittest.TestCase):