Repository: mjs/imapclient

Issue:
MULTIAPPEND support
Originally reported by: **Erik Quaeghebeur (Bitbucket: [equaeghe](https://bitbucket.org/equaeghe))**

---

See https://tools.ietf.org/html/rfc3502 (and perhaps updates).

It would seem impossible to do this by extending the append method without breaking backwards compatability, so perhaps an option is to add a multiappend method taking an iterator over {'msg': ...[, 'flags': ...][, 'date': ...]}-dicts that can fall-back to multiple appends.

This kind of functionality may be useful for clients also working in disconnected mode.

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


Comments:

Patch:
diff --git a/imapclient/imapclient.py b/imapclient/imapclient.py
index ac89622..46e9257 100644
--- a/imapclient/imapclient.py
+++ b/imapclient/imapclient.py
@@ -1288,6 +1288,23 @@ class IMAPClient(object):
                                        to_bytes(msg),
                                        unpack=True)
 
+    @require_capability('MULTIAPPEND')
+    def multiappend(self, folder, msgs):
+        """Append messages to *folder* using the MULTIAPPEND feature from :rfc:`3502`.
+
+        *msgs* should be a list of strings containing the full message including
+        headers.
+
+        Returns the APPEND response from the server.
+        """
+        msgs = [_literal(to_bytes(m)) for m in msgs]
+
+        return self._raw_command(
+            b'APPEND',
+            [self._normalise_folder(folder)] + msgs,
+            uid=False,
+        )
+
     def copy(self, messages, folder):
         """Copy one or more messages from the current folder to
         *folder*. Returns the COPY response string returned by the
@@ -1538,8 +1555,14 @@ class IMAPClient(object):
     def _send_literal(self, tag, item):
         """Send a single literal for the command with *tag*.
         """
+        if b'LITERAL+' in self._cached_capabilities:
+            out = b' {' + str(len(item)).encode('ascii') + b'+}\r\n' + item
+            logger.debug('> %s', debug_trunc(out, 64))
+            self._imap.send(out)
+            return
+
         out = b' {' + str(len(item)).encode('ascii') + b'}\r\n'
-        logger.debug(out)
+        logger.debug('> %s', out)
         self._imap.send(out)
 
         # Wait for continuation response
@@ -1666,6 +1689,9 @@ def _normalise_sort_criteria(criteria, charset=None):
         criteria = [criteria]
     return b'(' + b' '.join(to_bytes(item).upper() for item in criteria) + b')'
 
+class _literal(bytes):
+    """Hold message data that should always be sent as a literal."""
+    pass
 
 class _quoted(binary_type):
     """
@@ -1764,7 +1790,7 @@ def as_triplets(items):
 
 
 def _is8bit(data):
-    return any(b > 127 for b in iterbytes(data))
+    return isinstance(data, _literal) or any(b > 127 for b in iterbytes(data))
 
 
 def _iter_with_last(items):


Test patch:
diff --git a/tests/test_imapclient.py b/tests/test_imapclient.py
index 3d6dd74..13afd05 100644
--- a/tests/test_imapclient.py
+++ b/tests/test_imapclient.py
@@ -18,7 +18,7 @@ from imapclient.exceptions import (
 )
 from imapclient.imapclient import (
     IMAPlibLoggerAdapter, _parse_quota, Quota, MailboxQuotaRoots,
-    require_capability
+    require_capability, _literal
 )
 from imapclient.fixed_offset import FixedOffset
 from imapclient.testable_imapclient import TestableIMAPClient as IMAPClient
@@ -270,6 +270,14 @@ class TestAppend(IMAPClientTest):
         self.client._imap.append.assert_called_with(
             b'"foobar"', '(FLAG WAVE)', '"somedate"', msg)
 
+    def test_multiappend(self):
+        self.client._cached_capabilities = (b'MULTIAPPEND',)
+        self.client._raw_command = Mock()
+        self.client.multiappend('foobar', ['msg1', 'msg2'])
+
+        self.client._raw_command.assert_called_once_with(
+            b'APPEND', [b'"foobar"', b'msg1', b'msg2'], uid=False
+        )
 
 class TestAclMethods(IMAPClientTest):
 
@@ -805,6 +813,7 @@ class TestRawCommand(IMAPClientTest):
         super(TestRawCommand, self).setUp()
         self.client._imap._get_response.return_value = None
         self.client._imap._command_complete.return_value = ('OK', ['done'])
+        self.client._cached_capabilities = ()
 
     def check(self, command, args, expected):
         typ, data = self.client._raw_command(command, args)
@@ -842,6 +851,17 @@ class TestRawCommand(IMAPClientTest):
                    b'\xcc\r\n'
                    )
 
+    def test_literal_plus(self):
+        self.client._cached_capabilities = (b'LITERAL+',)
+
+        typ, data = self.client._raw_command(b'APPEND', [b'\xff', _literal(b'hello')], uid=False)
+        self.assertEqual(typ, 'OK')
+        self.assertEqual(data, ['done'])
+        self.assertEqual(self.client._imap.sent,
+                         b'tag APPEND {1+}\r\n'
+                         b'\xff  {5+}\r\n'
+                         b'hello\r\n')
+
     def test_complex(self):
         self.check(b'search', [b'FLAGGED', b'TEXT', b'\xfe\xff', b'TEXT', b'\xcc', b'TEXT', b'foo'],
                    b'tag UID SEARCH FLAGGED TEXT {2}\r\n'