Repository: mjs/imapclient

Issue:
quota support
Originally reported by: **Menno Smits (Bitbucket: [mjs0](https://bitbucket.org/mjs0))**

---

As per getquota, setquota and getquotaroot in imaplib

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


Comments:

Patch:
diff --git a/doc/src/concepts.rst b/doc/src/concepts.rst
index bacc41f..d67cf81 100644
--- a/doc/src/concepts.rst
+++ b/doc/src/concepts.rst
@@ -116,6 +116,14 @@ an up-to-date set of trusted CAs::
 
   ssl_context = ssl.create_default_context(cafile=certifi.where())
 
+If the server supports it, you can also authenticate using a client
+certificate::
+
+  import ssl
+
+  ssl_context = ssl.create_default_context()
+  ssl_context.load_cert_chain("/path/to/client_certificate.crt")
+
 The above examples show some of the most common TLS parameter
 customisations but there are many other tweaks are possible. Consult
 the Python 3 :py:mod:`ssl` package documentation for further options.
diff --git a/imapclient/imapclient.py b/imapclient/imapclient.py
index c383772..3613a5e 100644
--- a/imapclient/imapclient.py
+++ b/imapclient/imapclient.py
@@ -121,6 +121,27 @@ class SocketTimeout(namedtuple("SocketTimeout", "connect read")):
     """
 
 
+class MailboxQuotaRoots(namedtuple("MailboxQuotaRoots", "mailbox quota_roots")):
+    """Quota roots associated with a mailbox.
+
+    Represents the response of a GETQUOTAROOT command.
+
+    :ivar mailbox: the mailbox
+    :ivar quota_roots: list of quota roots associated with the mailbox
+    """
+
+class Quota(namedtuple("Quota", "quota_root resource usage limit")):
+    """Resource quota.
+
+    Represents the response of a GETQUOTA command.
+
+    :ivar quota_roots: the quota roots for which the limit apply
+    :ivar resource: the resource being limited (STORAGE, MESSAGES...)
+    :ivar usage: the current usage of the resource
+    :ivar limit: the maximum allowed usage of the resource
+    """
+
+
 def require_capability(capability):
     """Decorator raising CapabilityError when a capability is not available."""
 
@@ -1321,6 +1342,84 @@ class IMAPClient(object):
                                        who, what,
                                        unpack=True)
 
+    @require_capability('QUOTA')
+    def get_quota(self, mailbox="INBOX"):
+        """Get the quotas associated with a mailbox.
+
+        Returns a list of Quota objects.
+        """
+        return self.get_quota_root(mailbox)[1]
+
+    @require_capability('QUOTA')
+    def _get_quota(self, quota_root=""):
+        """Get the quotas associated with a quota root.
+
+        This method is not private but put behind an underscore to show that
+        it is a low-level function. Users probably want to use `get_quota`
+        instead.
+
+        Returns a list of Quota objects.
+        """
+        return _parse_quota(
+            self._command_and_check('getquota', _quote(quota_root))
+        )
+
+    @require_capability('QUOTA')
+    def get_quota_root(self, mailbox):
+        """Get the quota roots for a mailbox.
+
+        The IMAP server responds with the quota root and the quotas associated
+        so there is usually no need to call `get_quota` after.
+
+        See :rfc:`2087` for more details.
+
+        Return a tuple of MailboxQuotaRoots and list of Quota associated
+        """
+        quota_root_rep = self._raw_command_untagged(
+            b'GETQUOTAROOT', to_bytes(mailbox), uid=False,
+            response_name='QUOTAROOT'
+        )
+        quota_rep = pop_with_default(self._imap.untagged_responses, 'QUOTA', [])
+        quota_root_rep = parse_response(quota_root_rep)
+        quota_root = MailboxQuotaRoots(
+            to_unicode(quota_root_rep[0]),
+            [to_unicode(q) for q in quota_root_rep[1:]]
+        )
+        return quota_root, _parse_quota(quota_rep)
+
+    @require_capability('QUOTA')
+    def set_quota(self, quotas):
+        """Set one or more quotas on resources.
+
+        :param quotas: list of Quota objects
+        """
+        if not quotas:
+            return
+
+        quota_root = None
+        set_quota_args = list()
+
+        for quota in quotas:
+            if quota_root is None:
+                quota_root = quota.quota_root
+            elif quota_root != quota.quota_root:
+                raise ValueError("set_quota only accepts a single quota root")
+
+            set_quota_args.append(
+                "{} {}".format(quota.resource, quota.limit)
+            )
+
+        set_quota_args = " ".join(set_quota_args)
+        args = [
+            to_bytes(_quote(quota_root)),
+            to_bytes("({})".format(set_quota_args))
+        ]
+
+        response = self._raw_command_untagged(
+            b'SETQUOTA', args, uid=False, response_name='QUOTA'
+        )
+        return _parse_quota(response)
+
     def _check_resp(self, expected, command, typ, data):
         """Check command responses for errors.
 
@@ -1630,6 +1729,11 @@ def as_pairs(items):
         i += 1
 
 
+def as_triplets(items):
+    a = iter(items)
+    return zip(a, a, a)
+
+
 def _is8bit(data):
     return any(b > 127 for b in iterbytes(data))
 
@@ -1703,6 +1807,20 @@ def utf7_decode_sequence(seq):
     return [decode_utf7(s) for s in seq]
 
 
+def _parse_quota(quota_rep):
+    quota_rep = parse_response(quota_rep)
+    rv = list()
+    for quota_root, quota_resource_infos in as_pairs(quota_rep):
+        for quota_resource_info in as_triplets(quota_resource_infos):
+            rv.append(Quota(
+                quota_root=to_unicode(quota_root),
+                resource=to_unicode(quota_resource_info[0]),
+                usage=quota_resource_info[1],
+                limit=quota_resource_info[2]
+            ))
+    return rv
+
+
 class IMAPlibLoggerAdapter(LoggerAdapter):
     """Adapter preventing IMAP secrets from going to the logging facility."""
 
diff --git a/imapclient/interact.py b/imapclient/interact.py
index 2afe589..8908406 100644
--- a/imapclient/interact.py
+++ b/imapclient/interact.py
@@ -25,9 +25,11 @@ def command_line():
                  help='Password to login with')
     p.add_option('-P', '--port', dest='port', action='store', type=int,
                  default=None,
-                 help='IMAP port to use (default is 143, or 993 for SSL)')
-    p.add_option('-s', '--ssl', dest='ssl', action='store_true', default=False,
-                 help='Use SSL connection')
+                 help='IMAP port to use (default is 993 for TLS, or 143 otherwise)')
+    p.add_option('-s', '--ssl', dest='ssl', action='store_true', default=None,
+                 help='Use SSL/TLS connection (default)')
+    p.add_option('', '--insecure', dest='insecure', action='store_true', default=False,
+                 help='Use insecure connection (i.e. without SSL/TLS)')
     p.add_option('-f', '--file', dest='file', action='store', default=None,
                  help='Config file (same as livetest)')
 
@@ -36,11 +38,16 @@ def command_line():
         p.error('unexpected arguments %s' % ' '.join(args))
 
     if opts.file:
-        if opts.host or opts.username or opts.password or opts.port or opts.ssl:
+        if opts.host or opts.username or opts.password or opts.port or opts.ssl or opts.insecure:
             p.error('If -f/--file is given no other options can be used')
         # Use the options in the config file
         opts = parse_config_file(opts.file)
     else:
+        if opts.ssl and opts.insecure:
+            p.error("Can't use --ssl and --insecure at the same time")
+        
+        opts.ssl = not opts.insecure
+
         # Scan through options, filling in defaults and prompting when
         # a compulsory option wasn't provided.
         compulsory_opts = ('host', 'username', 'password')
diff --git a/imapclient/response_parser.py b/imapclient/response_parser.py
index 4331be6..6939fbb 100644
--- a/imapclient/response_parser.py
+++ b/imapclient/response_parser.py
@@ -160,8 +160,7 @@ def _int_or_error(value, error_text):
 
 
 def _convert_INTERNALDATE(date_string, normalise_times=True):
-    # Observed in https://sentry.nylas.com/sentry/sync-prod/group/5907/
-    if date_string.upper() == b'NIL':
+    if date_string is None:
         return None
 
     try:


Test patch:
diff --git a/tests/test_imapclient.py b/tests/test_imapclient.py
index f0bafa5..a2666b0 100644
--- a/tests/test_imapclient.py
+++ b/tests/test_imapclient.py
@@ -15,7 +15,10 @@ import six
 from imapclient.exceptions import (
     CapabilityError, IMAPClientError, ProtocolError
 )
-from imapclient.imapclient import IMAPlibLoggerAdapter, require_capability
+from imapclient.imapclient import (
+    IMAPlibLoggerAdapter, _parse_quota, Quota, MailboxQuotaRoots,
+    require_capability
+)
 from imapclient.fixed_offset import FixedOffset
 from imapclient.testable_imapclient import TestableIMAPClient as IMAPClient
 
@@ -276,6 +279,87 @@ class TestAclMethods(IMAPClientTest):
         self.assertEqual(response, b"SETACL done")
 
 
+class TestQuota(IMAPClientTest):
+
+    def setUp(self):
+        super(TestQuota, self).setUp()
+        self.client._cached_capabilities = [b'QUOTA']
+
+    def test_parse_quota(self):
+        self.assertEqual(_parse_quota([]), [])
+        self.assertEqual(
+            _parse_quota([b'"User quota" (STORAGE 586720 4882812)']),
+            [Quota('User quota', 'STORAGE', 586720, 4882812)]
+        )
+        self.assertEqual(
+            _parse_quota([
+                b'"User quota" (STORAGE 586720 4882812)',
+                b'"Global quota" (MESSAGES 42 1000)'
+            ]),
+            [
+                Quota('User quota', 'STORAGE', 586720, 4882812),
+                Quota('Global quota', 'MESSAGES', 42, 1000)
+            ]
+        )
+        self.assertEqual(
+            _parse_quota([
+                b'"User quota" (STORAGE 586720 4882812 MESSAGES 42 1000)',
+            ]),
+            [
+                Quota('User quota', 'STORAGE', 586720, 4882812),
+                Quota('User quota', 'MESSAGES', 42, 1000)
+            ]
+        )
+
+    def test__get_quota(self):
+        self.client._command_and_check = Mock()
+        self.client._command_and_check.return_value = (
+            [b'"User quota" (MESSAGES 42 1000)']
+        )
+
+        quotas = self.client._get_quota('foo')
+
+        self.client._command_and_check.assert_called_once_with(
+            'getquota', '"foo"'
+        )
+        self.assertEqual(quotas, [Quota('User quota', 'MESSAGES', 42, 1000)])
+
+    def test_set_quota(self):
+        self.client._raw_command_untagged = Mock()
+        self.client._raw_command_untagged.return_value = (
+            [b'"User quota" (STORAGE 42 1000 MESSAGES 42 1000)']
+        )
+        quotas = [
+            Quota('User quota', 'STORAGE', 42, 1000),
+            Quota('User quota', 'MESSAGES', 42, 1000)
+        ]
+        resp = self.client.set_quota(quotas)
+
+        self.client._raw_command_untagged.assert_called_once_with(
+            b'SETQUOTA', [b'"User quota"', b'(STORAGE 1000 MESSAGES 1000)'],
+            uid=False, response_name='QUOTA'
+        )
+        self.assertListEqual(resp, quotas)
+
+    def test_get_quota_root(self):
+        self.client._raw_command_untagged = Mock()
+        self.client._raw_command_untagged.return_value = (
+            [b'"INBOX" "User quota"']
+        )
+        self.client._imap.untagged_responses = dict()
+
+        resp = self.client.get_quota_root("INBOX")
+
+        self.client._raw_command_untagged.assert_called_once_with(
+            b'GETQUOTAROOT', b'INBOX', uid=False, response_name='QUOTAROOT'
+        )
+        expected = (MailboxQuotaRoots("INBOX", ["User quota"]), list())
+        self.assertTupleEqual(resp, expected)
+
+        resp = self.client.get_quota("INBOX")
+        self.assertEqual(resp, [])
+
+
 class TestIdleAndNoop(IMAPClientTest):
 
     def setUp(self):
diff --git a/tests/test_response_parser.py b/tests/test_response_parser.py
index 04694fc..0e87381 100644
--- a/tests/test_response_parser.py
+++ b/tests/test_response_parser.py
@@ -473,6 +473,15 @@ class TestParseFetchResponse(unittest.TestCase):
             datetime(2007, 2, 9, 17, 8, 8, 0, FixedOffset(-4 * 60 - 30)))
         self.assertEqual(dt, expected_dt)
 
+    def test_INTERNALDATE_NIL(self):
+        out = parse_fetch_response(
+            [b'1 (INTERNALDATE NIL)']
+        )
+        self.assertEqual(
+            out[1][b'INTERNALDATE'],
+            None
+        )
+
     def test_mixed_types(self):
         self.assertEqual(parse_fetch_response([(
             b'1 (INTERNALDATE " 9-Feb-2007 17:08:08 +0100" RFC822 {21}',