Repository: mjs/imapclient

Issue:
Support ENABLE extension
Originally reported by: **Menno Smits (Bitbucket: [mjs0](https://bitbucket.org/mjs0))**

---

https://tools.ietf.org/html/rfc5161

Use this to turn on CONDSTORE in livetests (better than current hack for Dovecot)

---
- Bitbucket: https://bitbucket.org/mjs0/imapclient/issue/139


Discussion:

Patch:
diff --git a/imapclient/imapclient.py b/imapclient/imapclient.py
index 4452d6f..ebc13dd 100644
--- a/imapclient/imapclient.py
+++ b/imapclient/imapclient.py
@@ -39,11 +39,11 @@ __all__ = ['IMAPClient', 'DELETED', 'SEEN', 'ANSWERED', 'FLAGGED', 'DRAFT', 'REC
 
 # We also offer the gmail-specific XLIST command...
 if 'XLIST' not in imaplib.Commands:
-    imaplib.Commands['XLIST'] = imaplib.Commands['LIST']
+    imaplib.Commands['XLIST'] = ('NONAUTH', 'AUTH', 'SELECTED')
 
 # ...and IDLE
 if 'IDLE' not in imaplib.Commands:
-    imaplib.Commands['IDLE'] = imaplib.Commands['APPEND']
+    imaplib.Commands['IDLE'] = ('NONAUTH', 'AUTH', 'SELECTED')
 
 # ..and STARTTLS
 if 'STARTTLS' not in imaplib.Commands:
@@ -60,6 +60,11 @@ if 'ID' not in imaplib.Commands:
 if 'UNSELECT' not in imaplib.Commands:
     imaplib.Commands['UNSELECT'] = ('AUTH', 'SELECTED')
 
+# .. and ENABLE.
+if 'ENABLE' not in imaplib.Commands:
+    imaplib.Commands['ENABLE'] = ('AUTH',)
+
+
 # System flags
 DELETED = br'\Deleted'
 SEEN = br'\Seen'
@@ -268,6 +273,35 @@ class IMAPClient(object):
         """
         self._imap.shutdown()
 
+    def enable(self, *capabilities):
+        """Activate one or more server side capability extensions.
+
+        Most capabilities do not need to be enabled. This is only
+        required for extensions which introduce backwards incompatible
+        behaviour. Two capabilities which may require enable are
+        ``CONDSTORE`` and ``UTF8=ACCEPT``.
+
+        A list of the requested extensions that were successfully
+        enabled on the server is returned.
+
+        Once enabled each extension remains active until the IMAP
+        connection is closed.
+
+        See :rfc:`5161` for more details.
+        """
+        if self._imap.state != 'AUTH':
+            raise self.Error("ENABLE command illegal in state %s" % self._imap.state)
+
+        resp = self._raw_command_untagged(
+            b'ENABLE',
+            [to_bytes(c) for c in capabilities],
+            uid=False,
+            response_name='ENABLED',
+            unpack=True)
+        if not resp:
+            return []
+        return resp.split()
+
     def id_(self, parameters=None):
         """Issue the ID command, returning a dict of server implementation
         fields.
@@ -1117,16 +1151,18 @@ class IMAPClient(object):
         self._checkok(command, typ, data)
         return data[0], resps
 
-    def _raw_command_untagged(self, command, args, unpack=False):
+    def _raw_command_untagged(self, command, args, response_name=None, unpack=False, uid=True):
         # TODO: eventually this should replace _command_and_check (call it _command)
-        typ, data = self._raw_command(command, args)
-        typ, data = self._imap._untagged_response(typ, data, to_unicode(command))
+        typ, data = self._raw_command(command, args, uid=uid)
+        if response_name is None:
+            response_name = command
+        typ, data = self._imap._untagged_response(typ, data, to_unicode(response_name))
         self._checkok(to_unicode(command), typ, data)
         if unpack:
             return data[0]
         return data
 
-    def _raw_command(self, command, args):
+    def _raw_command(self, command, args, uid=True):
         """Run the specific command with the arguments given. 8-bit arguments
         are sent as literals. The return value is (typ, data).
 
@@ -1150,7 +1186,7 @@ class IMAPClient(object):
 
         tag = self._imap._new_tag()
         prefix = [to_bytes(tag)]
-        if self.use_uid:
+        if uid and self.use_uid:
             prefix.append(b'UID')
         prefix.append(command)
 


Test patch:
diff --git a/imapclient/livetest.py b/imapclient/livetest.py
index 6260568..4192716 100644
--- a/imapclient/livetest.py
+++ b/imapclient/livetest.py
@@ -76,8 +76,11 @@ class _TestBase(unittest.TestCase):
 
     @classmethod
     def setUpClass(cls):
-        cls.client = create_client_from_config(cls.conf)
-        cls.client.use_uid = cls.use_uid
+        client = create_client_from_config(cls.conf)
+        cls.client = client
+        client.use_uid = cls.use_uid
+        if client.has_capability('ENABLE') and client.has_capability('CONDSTORE'):
+            client.enable('CONDSTORE')
         cls.base_folder = cls.conf.namespace[0] + '__imapclient'
         cls.folder_delimiter = cls.conf.namespace[1]
 
@@ -642,19 +645,6 @@ def createUidTestClass(conf, use_uid):
             if not self.client.has_capability('CONDSTORE'):
                 return self.skipTest("Server doesn't support CONDSTORE")
 
-            if self.is_gmail():
-                return self.skipTest(
-                    "Gmail doesn't seem to return MODSEQ parts in SEARCH responses")
-
-            # A little dance to ensure MODSEQ tracking is turned on.
-            # TODO: use ENABLE for this instead
-            self.client.select_folder(self.base_folder)
-            self.append_msg(SIMPLE_MESSAGE)
-            msg_id = self.client.search()[0]
-            self.client.fetch(msg_id, ["MODSEQ"])
-            self.client.close_folder()
-            self.clear_folder(self.base_folder)
-
             # Remember the initial MODSEQ
             initial_modseq = self.client.select_folder(self.base_folder)[b'HIGHESTMODSEQ']
 
@@ -833,22 +823,10 @@ def createUidTestClass(conf, use_uid):
 
         def test_fetch_modifiers(self):
             # CONDSTORE (RFC 4551) provides a good way to use FETCH
-            # modifiers but it isn't commonly available.
+            # modifiers but it isn't always available.
             if not self.client.has_capability('CONDSTORE'):
                 return self.skipTest("Server doesn't support CONDSTORE")
 
-            # A little dance to ensure MODSEQ tracking is turned on.
-            self.client.select_folder(self.base_folder)
-            self.append_msg(SIMPLE_MESSAGE)
-            msg_id = self.client.search()[0]
-            self.client.fetch(msg_id, ["MODSEQ"])
-            self.client.close_folder()
-            self.clear_folder(self.base_folder)
-
-            #
-            # Actual testing starts here
-            #
-
             # Get the starting MODSEQ
             modseq = self.client.select_folder(self.base_folder)[b'HIGHESTMODSEQ']
 
diff --git a/imapclient/test/test_enable.py b/imapclient/test/test_enable.py
new file mode 100644
index 0000000..2d1fa10
--- /dev/null
+++ b/imapclient/test/test_enable.py
@@ -0,0 +1,70 @@
+# Copyright (c) 2017, Menno Smits
+# Released subject to the New BSD License
+# Please see http://en.wikipedia.org/wiki/BSD_licenses
+
+from __future__ import unicode_literals
+
+from mock import Mock
+
+from imapclient import IMAPClient
+from .imapclient_test import IMAPClientTest
+
+
+class TestEnable(IMAPClientTest):
+
+    def setUp(self):
+        super(TestEnable, self).setUp()
+        self.command = Mock()
+        self.client._raw_command_untagged = self.command
+        self.client._imap.state = 'AUTH'
+
+    def test_success(self):
+        self.command.return_value = b'CONDSTORE'
+
+        resp = self.client.enable('CONDSTORE')
+
+        self.command.assert_called_once_with(
+            b'ENABLE', [b'CONDSTORE'],
+            uid=False, response_name='ENABLED', unpack=True)
+        self.assertEqual(resp, [b'CONDSTORE'])
+
+    def test_failed1(self):
+        # When server returns an empty ENABLED response
+        self.command.return_value = b''
+
+        resp = self.client.enable('FOO')
+
+        self.command.assert_called_once_with(
+            b'ENABLE', [b'FOO'],
+            uid=False, response_name='ENABLED', unpack=True)
+        self.assertEqual(resp, [])
+
+    def test_failed2(self):
+        # When server returns no ENABLED response
+        self.command.return_value = None
+
+        resp = self.client.enable('FOO')
+
+        self.command.assert_called_once_with(
+            b'ENABLE', [b'FOO'],
+            uid=False, response_name='ENABLED', unpack=True)
+        self.assertEqual(resp, [])
+
+    def test_multiple(self):
+        self.command.return_value = b'FOO BAR'
+
+        resp = self.client.enable('FOO', 'BAR')
+
+        self.command.assert_called_once_with(
+            b'ENABLE', [b'FOO', b'BAR'],
+            uid=False, response_name='ENABLED', unpack=True)
+        self.assertEqual(resp, [b'FOO', b'BAR'])
+
+    def test_wrong_state(self):
+        self.client._imap.state = 'SELECTED'
+
+        self.assertRaises(
+            IMAPClient.Error,
+            self.client.enable,
+            'FOO',
+        )