summaryrefslogtreecommitdiff
path: root/src/leap/bitmask/mail/imap
diff options
context:
space:
mode:
Diffstat (limited to 'src/leap/bitmask/mail/imap')
-rw-r--r--src/leap/bitmask/mail/imap/__init__.py0
-rw-r--r--src/leap/bitmask/mail/imap/account.py498
-rw-r--r--src/leap/bitmask/mail/imap/mailbox.py970
-rw-r--r--src/leap/bitmask/mail/imap/messages.py254
-rw-r--r--src/leap/bitmask/mail/imap/server.py693
-rw-r--r--src/leap/bitmask/mail/imap/service/README.rst39
-rw-r--r--src/leap/bitmask/mail/imap/service/__init__.py0
-rw-r--r--src/leap/bitmask/mail/imap/service/imap-server.tac145
-rw-r--r--src/leap/bitmask/mail/imap/service/imap.py208
-rw-r--r--src/leap/bitmask/mail/imap/service/manhole.py130
-rw-r--r--src/leap/bitmask/mail/imap/service/notes.txt81
-rw-r--r--src/leap/bitmask/mail/imap/service/rfc822.message86
-rw-r--r--src/leap/bitmask/mail/imap/tests/.gitignore1
-rwxr-xr-xsrc/leap/bitmask/mail/imap/tests/getmail344
-rwxr-xr-xsrc/leap/bitmask/mail/imap/tests/imapclient.py207
-rwxr-xr-xsrc/leap/bitmask/mail/imap/tests/regressions_mime_struct461
l---------src/leap/bitmask/mail/imap/tests/rfc822.message1
l---------src/leap/bitmask/mail/imap/tests/rfc822.multi-minimal.message1
l---------src/leap/bitmask/mail/imap/tests/rfc822.multi-nested.message1
l---------src/leap/bitmask/mail/imap/tests/rfc822.multi-signed.message1
l---------src/leap/bitmask/mail/imap/tests/rfc822.multi.message1
l---------src/leap/bitmask/mail/imap/tests/rfc822.plain.message1
-rwxr-xr-xsrc/leap/bitmask/mail/imap/tests/stress_tests_imap.zsh178
-rw-r--r--src/leap/bitmask/mail/imap/tests/test_imap.py1060
-rw-r--r--src/leap/bitmask/mail/imap/tests/walktree.py127
25 files changed, 5488 insertions, 0 deletions
diff --git a/src/leap/bitmask/mail/imap/__init__.py b/src/leap/bitmask/mail/imap/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/__init__.py
diff --git a/src/leap/bitmask/mail/imap/account.py b/src/leap/bitmask/mail/imap/account.py
new file mode 100644
index 0000000..e795c1b
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/account.py
@@ -0,0 +1,498 @@
+# -*- coding: utf-8 -*-
+# account.py
+# Copyright (C) 2013-2015 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Soledad Backed IMAP Account.
+"""
+import logging
+import os
+import time
+from functools import partial
+
+from twisted.internet import defer
+from twisted.mail import imap4
+from twisted.python import log
+from zope.interface import implements
+
+from leap.common.check import leap_assert, leap_assert_type
+
+from leap.mail.constants import MessageFlags
+from leap.mail.mail import Account
+from leap.mail.imap.mailbox import IMAPMailbox, normalize_mailbox
+from leap.soledad.client import Soledad
+
+logger = logging.getLogger(__name__)
+
+PROFILE_CMD = os.environ.get('LEAP_PROFILE_IMAPCMD', False)
+
+if PROFILE_CMD:
+ def _debugProfiling(result, cmdname, start):
+ took = (time.time() - start) * 1000
+ log.msg("CMD " + cmdname + " TOOK: " + str(took) + " msec")
+ return result
+
+
+#######################################
+# Soledad IMAP Account
+#######################################
+
+
+class IMAPAccount(object):
+ """
+ An implementation of an imap4 Account
+ that is backed by Soledad Encrypted Documents.
+ """
+
+ implements(imap4.IAccount, imap4.INamespacePresenter)
+
+ selected = None
+
+ def __init__(self, store, user_id, d=defer.Deferred()):
+ """
+ Keeps track of the mailboxes and subscriptions handled by this account.
+
+ The account is not ready to be used, since the store needs to be
+ initialized and we also need to do some initialization routines.
+ You can either pass a deferred to this constructor, or use
+ `callWhenReady` method.
+
+ :param store: a Soledad instance.
+ :type store: Soledad
+
+ :param user_id: The identifier of the user this account belongs to
+ (user id, in the form user@provider).
+ :type user_id: str
+
+
+ :param d: a deferred that will be fired with this IMAPAccount instance
+ when the account is ready to be used.
+ :type d: defer.Deferred
+ """
+ leap_assert(store, "Need a store instance to initialize")
+ leap_assert_type(store, Soledad)
+
+ # TODO assert too that the name matches the user/uuid with which
+ # soledad has been initialized. Although afaik soledad doesn't know
+ # about user_id, only the client backend.
+
+ self.user_id = user_id
+ self.account = Account(
+ store, user_id, ready_cb=lambda: d.callback(self))
+
+ def end_session(self):
+ """
+ Used to mark when the session has closed, and we should not allow any
+ more commands from the client.
+
+ Right now it's called from the client backend.
+ """
+ # TODO move its use to the service shutdown in leap.mail
+ self.account.end_session()
+
+ @property
+ def session_ended(self):
+ return self.account.session_ended
+
+ def callWhenReady(self, cb, *args, **kw):
+ """
+ Execute callback when the account is ready to be used.
+ XXX note that this callback will be called with a first ignored
+ parameter.
+ """
+ # TODO ignore the first parameter and change tests accordingly.
+ d = self.account.callWhenReady(cb, *args, **kw)
+ return d
+
+ def getMailbox(self, name):
+ """
+ Return a Mailbox with that name, without selecting it.
+
+ :param name: name of the mailbox
+ :type name: str
+
+ :returns: an IMAPMailbox instance
+ :rtype: IMAPMailbox
+ """
+ name = normalize_mailbox(name)
+
+ def check_it_exists(mailboxes):
+ if name not in mailboxes:
+ raise imap4.MailboxException("No such mailbox: %r" % name)
+ return True
+
+ d = self.account.list_all_mailbox_names()
+ d.addCallback(check_it_exists)
+ d.addCallback(lambda _: self.account.get_collection_by_mailbox(name))
+ d.addCallback(self._return_mailbox_from_collection)
+ return d
+
+ def _return_mailbox_from_collection(self, collection, readwrite=1):
+ if collection is None:
+ return None
+ mbox = IMAPMailbox(collection, rw=readwrite)
+ return mbox
+
+ #
+ # IAccount
+ #
+
+ def addMailbox(self, name, creation_ts=None):
+ """
+ Add a mailbox to the account.
+
+ :param name: the name of the mailbox
+ :type name: str
+
+ :param creation_ts: an optional creation timestamp to be used as
+ mailbox id. A timestamp will be used if no
+ one is provided.
+ :type creation_ts: int
+
+ :returns: a Deferred that will contain the document if successful.
+ :rtype: defer.Deferred
+ """
+ name = normalize_mailbox(name)
+
+ # FIXME --- return failure instead of AssertionError
+ # See AccountTestCase...
+ leap_assert(name, "Need a mailbox name to create a mailbox")
+
+ def check_it_does_not_exist(mailboxes):
+ if name in mailboxes:
+ raise imap4.MailboxCollision, repr(name)
+ return mailboxes
+
+ d = self.account.list_all_mailbox_names()
+ d.addCallback(check_it_does_not_exist)
+ d.addCallback(lambda _: self.account.add_mailbox(
+ name, creation_ts=creation_ts))
+ d.addCallback(lambda _: self.account.get_collection_by_mailbox(name))
+ d.addCallback(self._return_mailbox_from_collection)
+ return d
+
+ def create(self, pathspec):
+ """
+ Create a new mailbox from the given hierarchical name.
+
+ :param pathspec:
+ The full hierarchical name of a new mailbox to create.
+ If any of the inferior hierarchical names to this one
+ do not exist, they are created as well.
+ :type pathspec: str
+
+ :return:
+ A deferred that will fire with a true value if the creation
+ succeeds. The deferred might fail with a MailboxException
+ if the mailbox cannot be added.
+ :rtype: Deferred
+
+ """
+ def pass_on_collision(failure):
+ failure.trap(imap4.MailboxCollision)
+ return True
+
+ def handle_collision(failure):
+ failure.trap(imap4.MailboxCollision)
+ if not pathspec.endswith('/'):
+ return defer.succeed(False)
+ else:
+ return defer.succeed(True)
+
+ def all_good(result):
+ return all(result)
+
+ paths = filter(None, normalize_mailbox(pathspec).split('/'))
+ subs = []
+ sep = '/'
+
+ for accum in range(1, len(paths)):
+ partial_path = sep.join(paths[:accum])
+ d = self.addMailbox(partial_path)
+ d.addErrback(pass_on_collision)
+ subs.append(d)
+
+ df = self.addMailbox(sep.join(paths))
+ df.addErrback(handle_collision)
+ subs.append(df)
+
+ d1 = defer.gatherResults(subs)
+ d1.addCallback(all_good)
+ return d1
+
+ def select(self, name, readwrite=1):
+ """
+ Selects a mailbox.
+
+ :param name: the mailbox to select
+ :type name: str
+
+ :param readwrite: 1 for readwrite permissions.
+ :type readwrite: int
+
+ :rtype: IMAPMailbox
+ """
+ name = normalize_mailbox(name)
+
+ def check_it_exists(mailboxes):
+ if name not in mailboxes:
+ logger.warning("SELECT: No such mailbox!")
+ return None
+ return name
+
+ def set_selected(_):
+ self.selected = name
+
+ def get_collection(name):
+ if name is None:
+ return None
+ return self.account.get_collection_by_mailbox(name)
+
+ d = self.account.list_all_mailbox_names()
+ d.addCallback(check_it_exists)
+ d.addCallback(get_collection)
+ d.addCallback(partial(
+ self._return_mailbox_from_collection, readwrite=readwrite))
+ return d
+
+ def delete(self, name, force=False):
+ """
+ Deletes a mailbox.
+
+ :param name: the mailbox to be deleted
+ :type name: str
+
+ :param force:
+ if True, it will not check for noselect flag or inferior
+ names. use with care.
+ :type force: bool
+ :rtype: Deferred
+ """
+ name = normalize_mailbox(name)
+ _mboxes = None
+
+ def check_it_exists(mailboxes):
+ global _mboxes
+ _mboxes = mailboxes
+ if name not in mailboxes:
+ raise imap4.MailboxException("No such mailbox: %r" % name)
+
+ def get_mailbox(_):
+ return self.getMailbox(name)
+
+ def destroy_mailbox(mbox):
+ return mbox.destroy()
+
+ def check_can_be_deleted(mbox):
+ global _mboxes
+ # See if this box is flagged \Noselect
+ mbox_flags = mbox.getFlags()
+ if MessageFlags.NOSELECT_FLAG in mbox_flags:
+ # Check for hierarchically inferior mailboxes with this one
+ # as part of their root.
+ for others in _mboxes:
+ if others != name and others.startswith(name):
+ raise imap4.MailboxException(
+ "Hierarchically inferior mailboxes "
+ "exist and \\Noselect is set")
+ return mbox
+
+ d = self.account.list_all_mailbox_names()
+ d.addCallback(check_it_exists)
+ d.addCallback(get_mailbox)
+ if not force:
+ d.addCallback(check_can_be_deleted)
+ d.addCallback(destroy_mailbox)
+ return d
+
+ # FIXME --- not honoring the inferior names...
+ # if there are no hierarchically inferior names, we will
+ # delete it from our ken.
+ # XXX is this right?
+ # if self._inferiorNames(name) > 1:
+ # self._index.removeMailbox(name)
+
+ def rename(self, oldname, newname):
+ """
+ Renames a mailbox.
+
+ :param oldname: old name of the mailbox
+ :type oldname: str
+
+ :param newname: new name of the mailbox
+ :type newname: str
+ """
+ oldname = normalize_mailbox(oldname)
+ newname = normalize_mailbox(newname)
+
+ def rename_inferiors((inferiors, mailboxes)):
+ rename_deferreds = []
+ inferiors = [
+ (o, o.replace(oldname, newname, 1)) for o in inferiors]
+
+ for (old, new) in inferiors:
+ if new in mailboxes:
+ raise imap4.MailboxCollision(repr(new))
+
+ for (old, new) in inferiors:
+ d = self.account.rename_mailbox(old, new)
+ rename_deferreds.append(d)
+
+ d1 = defer.gatherResults(rename_deferreds, consumeErrors=True)
+ return d1
+
+ d1 = self._inferiorNames(oldname)
+ d2 = self.account.list_all_mailbox_names()
+
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(rename_inferiors)
+ return d
+
+ def _inferiorNames(self, name):
+ """
+ Return hierarchically inferior mailboxes.
+
+ :param name: name of the mailbox
+ :rtype: list
+ """
+ # XXX use wildcard query instead
+ def filter_inferiors(mailboxes):
+ inferiors = []
+ for infname in mailboxes:
+ if infname.startswith(name):
+ inferiors.append(infname)
+ return inferiors
+
+ d = self.account.list_all_mailbox_names()
+ d.addCallback(filter_inferiors)
+ return d
+
+ def listMailboxes(self, ref, wildcard):
+ """
+ List the mailboxes.
+
+ from rfc 3501:
+ returns a subset of names from the complete set
+ of all names available to the client. Zero or more untagged LIST
+ replies are returned, containing the name attributes, hierarchy
+ delimiter, and name.
+
+ :param ref: reference name
+ :type ref: str
+
+ :param wildcard: mailbox name with possible wildcards
+ :type wildcard: str
+ """
+ wildcard = imap4.wildcardToRegexp(wildcard, '/')
+
+ def get_list(mboxes, mboxes_names):
+ return zip(mboxes_names, mboxes)
+
+ def filter_inferiors(ref):
+ mboxes = [mbox for mbox in ref if wildcard.match(mbox)]
+ mbox_d = defer.gatherResults([self.getMailbox(m) for m in mboxes])
+
+ mbox_d.addCallback(get_list, mboxes)
+ return mbox_d
+
+ d = self._inferiorNames(normalize_mailbox(ref))
+ d.addCallback(filter_inferiors)
+ return d
+
+ #
+ # The rest of the methods are specific for leap.mail.imap.account.Account
+ #
+
+ def isSubscribed(self, name):
+ """
+ Returns True if user is subscribed to this mailbox.
+
+ :param name: the mailbox to be checked.
+ :type name: str
+
+ :rtype: Deferred (will fire with bool)
+ """
+ name = normalize_mailbox(name)
+
+ def get_subscribed(mbox):
+ return mbox.collection.get_mbox_attr("subscribed")
+
+ d = self.getMailbox(name)
+ d.addCallback(get_subscribed)
+ return d
+
+ def subscribe(self, name):
+ """
+ Subscribe to this mailbox if not already subscribed.
+
+ :param name: name of the mailbox
+ :type name: str
+ :rtype: Deferred
+ """
+ name = normalize_mailbox(name)
+
+ def set_subscribed(mbox):
+ return mbox.collection.set_mbox_attr("subscribed", True)
+
+ d = self.getMailbox(name)
+ d.addCallback(set_subscribed)
+ return d
+
+ def unsubscribe(self, name):
+ """
+ Unsubscribe from this mailbox
+
+ :param name: name of the mailbox
+ :type name: str
+ :rtype: Deferred
+ """
+ # TODO should raise MailboxException if attempted to unsubscribe
+ # from a mailbox that is not currently subscribed.
+ # TODO factor out with subscribe method.
+ name = normalize_mailbox(name)
+
+ def set_unsubscribed(mbox):
+ return mbox.collection.set_mbox_attr("subscribed", False)
+
+ d = self.getMailbox(name)
+ d.addCallback(set_unsubscribed)
+ return d
+
+ def getSubscriptions(self):
+ def get_subscribed(mailboxes):
+ return [x.mbox for x in mailboxes if x.subscribed]
+
+ d = self.account.get_all_mailboxes()
+ d.addCallback(get_subscribed)
+ return d
+
+ #
+ # INamespacePresenter
+ #
+
+ def getPersonalNamespaces(self):
+ return [["", "/"]]
+
+ def getSharedNamespaces(self):
+ return None
+
+ def getOtherNamespaces(self):
+ return None
+
+ def __repr__(self):
+ """
+ Representation string for this object.
+ """
+ return "<IMAPAccount (%s)>" % self.user_id
diff --git a/src/leap/bitmask/mail/imap/mailbox.py b/src/leap/bitmask/mail/imap/mailbox.py
new file mode 100644
index 0000000..e70a1d8
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/mailbox.py
@@ -0,0 +1,970 @@
+# *- coding: utf-8 -*-
+# mailbox.py
+# Copyright (C) 2013-2015 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+IMAP Mailbox.
+"""
+import re
+import logging
+import os
+import cStringIO
+import StringIO
+import time
+
+from collections import defaultdict
+from email.utils import formatdate
+
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.python import log
+
+from twisted.mail import imap4
+from zope.interface import implements
+
+from leap.common.check import leap_assert
+from leap.common.check import leap_assert_type
+from leap.mail.constants import INBOX_NAME, MessageFlags
+from leap.mail.imap.messages import IMAPMessage
+
+logger = logging.getLogger(__name__)
+
+# TODO LIST
+# [ ] Restore profile_cmd instrumentation
+# [ ] finish the implementation of IMailboxListener
+# [ ] implement the rest of ISearchableMailbox
+
+
+"""
+If the environment variable `LEAP_SKIPNOTIFY` is set, we avoid
+notifying clients of new messages. Use during stress tests.
+"""
+NOTIFY_NEW = not os.environ.get('LEAP_SKIPNOTIFY', False)
+PROFILE_CMD = os.environ.get('LEAP_PROFILE_IMAPCMD', False)
+
+if PROFILE_CMD:
+
+ def _debugProfiling(result, cmdname, start):
+ took = (time.time() - start) * 1000
+ log.msg("CMD " + cmdname + " TOOK: " + str(took) + " msec")
+ return result
+
+ def do_profile_cmd(d, name):
+ """
+ Add the profiling debug to the passed callback.
+ :param d: deferred
+ :param name: name of the command
+ :type name: str
+ """
+ d.addCallback(_debugProfiling, name, time.time())
+ d.addErrback(lambda f: log.msg(f.getTraceback()))
+
+INIT_FLAGS = (MessageFlags.SEEN_FLAG, MessageFlags.ANSWERED_FLAG,
+ MessageFlags.FLAGGED_FLAG, MessageFlags.DELETED_FLAG,
+ MessageFlags.DRAFT_FLAG, MessageFlags.RECENT_FLAG,
+ MessageFlags.LIST_FLAG)
+
+
+def make_collection_listener(mailbox):
+ """
+ Wrap a mailbox in a class that can be hashed according to the mailbox name.
+
+ This means that dicts or sets will use this new equality rule, so we won't
+ collect multiple instances of the same mailbox in collections like the
+ MessageCollection set where we keep track of listeners.
+ """
+
+ class HashableMailbox(object):
+
+ def __init__(self, mbox):
+ self.mbox = mbox
+
+ # See #8083, pixelated adaptor seems to be misusing this class.
+ self.mailbox_name = self.mbox.mbox_name
+
+ def __hash__(self):
+ return hash(self.mbox.mbox_name)
+
+ def __eq__(self, other):
+ return self.mbox.mbox_name == other.mbox.mbox_name
+
+ def notify_new(self):
+ self.mbox.notify_new()
+
+ return HashableMailbox(mailbox)
+
+
+class IMAPMailbox(object):
+ """
+ A Soledad-backed IMAP mailbox.
+
+ Implements the high-level method needed for the Mailbox interfaces.
+ The low-level database methods are contained in the generic
+ MessageCollection class. We receive an instance of it and it is made
+ accessible in the `collection` attribute.
+ """
+ implements(
+ imap4.IMailbox,
+ imap4.IMailboxInfo,
+ imap4.ISearchableMailbox,
+ # XXX I think we do not need to implement CloseableMailbox, do we?
+ # We could remove ourselves from the collectionListener, although I
+ # think it simply will be garbage collected.
+ # imap4.ICloseableMailbox
+ imap4.IMessageCopier)
+
+ init_flags = INIT_FLAGS
+
+ CMD_MSG = "MESSAGES"
+ CMD_RECENT = "RECENT"
+ CMD_UIDNEXT = "UIDNEXT"
+ CMD_UIDVALIDITY = "UIDVALIDITY"
+ CMD_UNSEEN = "UNSEEN"
+
+ # TODO we should turn this into a datastructure with limited capacity
+ _listeners = defaultdict(set)
+
+ def __init__(self, collection, rw=1):
+ """
+ :param collection: instance of MessageCollection
+ :type collection: MessageCollection
+
+ :param rw: read-and-write flag for this mailbox
+ :type rw: int
+ """
+ self.rw = rw
+ self._uidvalidity = None
+ self.collection = collection
+ self.collection.addListener(make_collection_listener(self))
+
+ @property
+ def mbox_name(self):
+ return self.collection.mbox_name
+
+ @property
+ def listeners(self):
+ """
+ Returns listeners for this mbox.
+
+ The server itself is a listener to the mailbox.
+ so we can notify it (and should!) after changes in flags
+ and number of messages.
+
+ :rtype: set
+ """
+ return self._listeners[self.mbox_name]
+
+ def get_imap_message(self, message):
+ d = defer.Deferred()
+ IMAPMessage(message, store=self.collection.store, d=d)
+ return d
+
+ # FIXME this grows too crazily when many instances are fired, like
+ # during imaptest stress testing. Should have a queue of limited size
+ # instead.
+
+ def addListener(self, listener):
+ """
+ Add a listener to the listeners queue.
+ The server adds itself as a listener when there is a SELECT,
+ so it can send EXIST commands.
+
+ :param listener: listener to add
+ :type listener: an object that implements IMailboxListener
+ """
+ if not NOTIFY_NEW:
+ return
+
+ listeners = self.listeners
+ logger.debug('adding mailbox listener: %s. Total: %s' % (
+ listener, len(listeners)))
+ listeners.add(listener)
+
+ def removeListener(self, listener):
+ """
+ Remove a listener from the listeners queue.
+
+ :param listener: listener to remove
+ :type listener: an object that implements IMailboxListener
+ """
+ self.listeners.remove(listener)
+
+ def getFlags(self):
+ """
+ Returns the flags defined for this mailbox.
+
+ :returns: tuple of flags for this mailbox
+ :rtype: tuple of str
+ """
+ flags = self.collection.mbox_wrapper.flags
+ if not flags:
+ flags = self.init_flags
+ flags_str = map(str, flags)
+ return flags_str
+
+ def setFlags(self, flags):
+ """
+ Sets flags for this mailbox.
+
+ :param flags: a tuple with the flags
+ :type flags: tuple of str
+ """
+ # XXX this is setting (overriding) old flags.
+ # Better pass a mode flag
+ leap_assert(isinstance(flags, tuple),
+ "flags expected to be a tuple")
+ return self.collection.set_mbox_attr("flags", flags)
+
+ def getUIDValidity(self):
+ """
+ Return the unique validity identifier for this mailbox.
+
+ :return: unique validity identifier
+ :rtype: int
+ """
+ return self.collection.get_mbox_attr("created")
+
+ def getUID(self, message_number):
+ """
+ Return the UID of a message in the mailbox
+
+ .. note:: this implementation does not make much sense RIGHT NOW,
+ but in the future will be useful to get absolute UIDs from
+ message sequence numbers.
+
+
+ :param message: the message sequence number.
+ :type message: int
+
+ :rtype: int
+ :return: the UID of the message.
+
+ """
+ # TODO support relative sequences. The (imap) message should
+ # receive a sequence number attribute: a deferred is not expected
+ return message_number
+
+ def getUIDNext(self):
+ """
+ Return the likely UID for the next message added to this
+ mailbox. Currently it returns the higher UID incremented by
+ one.
+
+ :return: deferred with int
+ :rtype: Deferred
+ """
+ d = self.collection.get_uid_next()
+ return d
+
+ def getMessageCount(self):
+ """
+ Returns the total count of messages in this mailbox.
+
+ :return: deferred with int
+ :rtype: Deferred
+ """
+ return self.collection.count()
+
+ def getUnseenCount(self):
+ """
+ Returns the number of messages with the 'Unseen' flag.
+
+ :return: count of messages flagged `unseen`
+ :rtype: int
+ """
+ return self.collection.count_unseen()
+
+ def getRecentCount(self):
+ """
+ Returns the number of messages with the 'Recent' flag.
+
+ :return: count of messages flagged `recent`
+ :rtype: int
+ """
+ return self.collection.count_recent()
+
+ def isWriteable(self):
+ """
+ Get the read/write status of the mailbox.
+
+ :return: 1 if mailbox is read-writeable, 0 otherwise.
+ :rtype: int
+ """
+ # XXX We don't need to store it in the mbox doc, do we?
+ # return int(self.collection.get_mbox_attr('rw'))
+ return self.rw
+
+ def getHierarchicalDelimiter(self):
+ """
+ Returns the character used to delimite hierarchies in mailboxes.
+
+ :rtype: str
+ """
+ return '/'
+
+ def requestStatus(self, names):
+ """
+ Handles a status request by gathering the output of the different
+ status commands.
+
+ :param names: a list of strings containing the status commands
+ :type names: iter
+ """
+ r = {}
+ maybe = defer.maybeDeferred
+ if self.CMD_MSG in names:
+ r[self.CMD_MSG] = maybe(self.getMessageCount)
+ if self.CMD_RECENT in names:
+ r[self.CMD_RECENT] = maybe(self.getRecentCount)
+ if self.CMD_UIDNEXT in names:
+ r[self.CMD_UIDNEXT] = maybe(self.getUIDNext)
+ if self.CMD_UIDVALIDITY in names:
+ r[self.CMD_UIDVALIDITY] = maybe(self.getUIDValidity)
+ if self.CMD_UNSEEN in names:
+ r[self.CMD_UNSEEN] = maybe(self.getUnseenCount)
+
+ def as_a_dict(values):
+ return dict(zip(r.keys(), values))
+
+ d = defer.gatherResults(r.values())
+ d.addCallback(as_a_dict)
+ return d
+
+ def addMessage(self, message, flags, date=None, notify_just_mdoc=True):
+ """
+ Adds a message to this mailbox.
+
+ :param message: the raw message
+ :type message: str
+
+ :param flags: flag list
+ :type flags: list of str
+
+ :param date: timestamp
+ :type date: str, or None
+
+ :param notify_just_mdoc:
+ boolean passed to the wrapper.create method, to indicate whether
+ we're insterested in being notified right after the mdoc has been
+ written (as it's the first doc to be written, and quite small, this
+ is faster, though potentially unsafe).
+ Setting it to True improves a *lot* the responsiveness of the
+ APPENDS: we just need to be notified when the mdoc is saved, and
+ let's just expect that the other parts are doing just fine. This
+ will not catch any errors when the inserts of the other parts
+ fail, but on the other hand allows us to return very quickly,
+ which seems a good compromise given that we have to serialize the
+ appends.
+ However, some operations like the saving of drafts need to wait for
+ all the parts to be saved, so if some heuristics are met down in
+ the call chain a Draft message will unconditionally set this flag
+ to False, and therefore ignoring the setting of this flag here.
+ :type notify_just_mdoc: bool
+
+ :return: a deferred that will be triggered with the UID of the added
+ message.
+ """
+ # TODO should raise ReadOnlyMailbox if not rw.
+ # TODO have a look at the cases for internal date in the rfc
+ # XXX we could treat the message as an IMessage from here
+
+ # TODO change notify_just_mdoc to something more meaningful, like
+ # fast_insert_notify?
+
+ # TODO notify_just_mdoc *sometimes* make the append tests fail.
+ # have to find a better solution for this. A workaround could probably
+ # be to have a list of the ongoing deferreds related to append, so that
+ # we queue for later all the requests having to do with these.
+
+ # A better solution will probably involve implementing MULTIAPPEND
+ # extension or patching imap server to support pipelining.
+
+ if isinstance(message, (cStringIO.OutputType, StringIO.StringIO)):
+ message = message.getvalue()
+
+ leap_assert_type(message, basestring)
+
+ if flags is None:
+ flags = tuple()
+ else:
+ flags = tuple(str(flag) for flag in flags)
+
+ if date is None:
+ date = formatdate(time.time())
+
+ d = self.collection.add_msg(message, flags, date=date,
+ notify_just_mdoc=notify_just_mdoc)
+ d.addErrback(lambda failure: log.err(failure))
+ return d
+
+ def notify_new(self, *args):
+ """
+ Notify of new messages to all the listeners.
+
+ This will be called indirectly by the underlying collection, that will
+ notify this IMAPMailbox whenever there are changes in the number of
+ messages in the collection, since we have added ourselves to the
+ collection listeners.
+
+ :param args: ignored.
+ """
+ if not NOTIFY_NEW:
+ return
+
+ def cbNotifyNew(result):
+ exists, recent = result
+ for listener in self.listeners:
+ listener.newMessages(exists, recent)
+
+ d = self._get_notify_count()
+ d.addCallback(cbNotifyNew)
+ d.addCallback(self.collection.cb_signal_unread_to_ui)
+ d.addErrback(lambda failure: log.err(failure))
+
+ def _get_notify_count(self):
+ """
+ Get message count and recent count for this mailbox.
+
+ :return: a deferred that will fire with a tuple, with number of
+ messages and number of recent messages.
+ :rtype: Deferred
+ """
+ # XXX this is way too expensive in cases like multiple APPENDS.
+ # We should have a way of keep a cache or do a self-increment for that
+ # kind of calls.
+ d_exists = defer.maybeDeferred(self.getMessageCount)
+ d_recent = defer.maybeDeferred(self.getRecentCount)
+ d_list = [d_exists, d_recent]
+
+ def log_num_msg(result):
+ exists, recent = tuple(result)
+ logger.debug("NOTIFY (%r): there are %s messages, %s recent" % (
+ self.mbox_name, exists, recent))
+ return result
+
+ d = defer.gatherResults(d_list)
+ d.addCallback(log_num_msg)
+ return d
+
+ # commands, do not rename methods
+
+ def destroy(self):
+ """
+ Called before this mailbox is permanently deleted.
+
+ Should cleanup resources, and set the \\Noselect flag
+ on the mailbox.
+
+ """
+ # XXX this will overwrite all the existing flags
+ # should better simply addFlag
+ self.setFlags((MessageFlags.NOSELECT_FLAG,))
+
+ def remove_mbox(_):
+ uuid = self.collection.mbox_uuid
+ d = self.collection.mbox_wrapper.delete(self.collection.store)
+ d.addCallback(
+ lambda _: self.collection.mbox_indexer.delete_table(uuid))
+ return d
+
+ d = self.deleteAllDocs()
+ d.addCallback(remove_mbox)
+ return d
+
+ def expunge(self):
+ """
+ Remove all messages flagged \\Deleted
+ """
+ if not self.isWriteable():
+ raise imap4.ReadOnlyMailbox
+ return self.collection.delete_all_flagged()
+
+ def _get_message_fun(self, uid):
+ """
+ Return the proper method to get a message for this mailbox, depending
+ on the passed uid flag.
+
+ :param uid: If true, the IDs specified in the query are UIDs;
+ otherwise they are message sequence IDs.
+ :type uid: bool
+ :rtype: callable
+ """
+ get_message_fun = [
+ self.collection.get_message_by_sequence_number,
+ self.collection.get_message_by_uid][uid]
+ return get_message_fun
+
+ def _get_messages_range(self, messages_asked, uid=True):
+
+ def get_range(messages_asked):
+ return self._filter_msg_seq(messages_asked)
+
+ d = self._bound_seq(messages_asked, uid)
+ if uid:
+ d.addCallback(get_range)
+ d.addErrback(lambda f: log.err(f))
+ return d
+
+ def _bound_seq(self, messages_asked, uid):
+ """
+ Put an upper bound to a messages sequence if this is open.
+
+ :param messages_asked: IDs of the messages.
+ :type messages_asked: MessageSet
+ :return: a Deferred that will fire with a MessageSet
+ """
+
+ def set_last_uid(last_uid):
+ messages_asked.last = last_uid
+ return messages_asked
+
+ def set_last_seq(all_uid):
+ messages_asked.last = len(all_uid)
+ return messages_asked
+
+ if not messages_asked.last:
+ try:
+ iter(messages_asked)
+ except TypeError:
+ # looks like we cannot iterate
+ if uid:
+ d = self.collection.get_last_uid()
+ d.addCallback(set_last_uid)
+ else:
+ d = self.collection.all_uid_iter()
+ d.addCallback(set_last_seq)
+ return d
+ return defer.succeed(messages_asked)
+
+ def _filter_msg_seq(self, messages_asked):
+ """
+ Filter a message sequence returning only the ones that do exist in the
+ collection.
+
+ :param messages_asked: IDs of the messages.
+ :type messages_asked: MessageSet
+ :rtype: set
+ """
+ # TODO we could pass the asked sequence to the indexer
+ # all_uid_iter, and bound the sql query instead.
+ def filter_by_asked(all_msg_uid):
+ set_asked = set(messages_asked)
+ set_exist = set(all_msg_uid)
+ return set_asked.intersection(set_exist)
+
+ d = self.collection.all_uid_iter()
+ d.addCallback(filter_by_asked)
+ return d
+
+ def fetch(self, messages_asked, uid):
+ """
+ Retrieve one or more messages in this mailbox.
+
+ from rfc 3501: The data items to be fetched can be either a single atom
+ or a parenthesized list.
+
+ :param messages_asked: IDs of the messages to retrieve information
+ about
+ :type messages_asked: MessageSet
+
+ :param uid: If true, the IDs are UIDs. They are message sequence IDs
+ otherwise.
+ :type uid: bool
+
+ :rtype: deferred with a generator that yields...
+ """
+ get_msg_fun = self._get_message_fun(uid)
+ getimapmsg = self.get_imap_message
+
+ def get_imap_messages_for_range(msg_range):
+
+ def _get_imap_msg(messages):
+ d_imapmsg = []
+ # just in case we got bad data in here
+ for msg in filter(None, messages):
+ d_imapmsg.append(getimapmsg(msg))
+ return defer.gatherResults(d_imapmsg, consumeErrors=True)
+
+ def _zip_msgid(imap_messages):
+ zipped = zip(
+ list(msg_range), imap_messages)
+ return (item for item in zipped)
+
+ # XXX not called??
+ def _unset_recent(sequence):
+ reactor.callLater(0, self.unset_recent_flags, sequence)
+ return sequence
+
+ d_msg = []
+ for msgid in msg_range:
+ # XXX We want cdocs because we "probably" are asked for the
+ # body. We should be smarter at do_FETCH and pass a parameter
+ # to this method in order not to prefetch cdocs if they're not
+ # going to be used.
+ d_msg.append(get_msg_fun(msgid, get_cdocs=True))
+
+ d = defer.gatherResults(d_msg, consumeErrors=True)
+ d.addCallback(_get_imap_msg)
+ d.addCallback(_zip_msgid)
+ d.addErrback(lambda failure: log.err(failure))
+ return d
+
+ d = self._get_messages_range(messages_asked, uid)
+ d.addCallback(get_imap_messages_for_range)
+ d.addErrback(lambda failure: log.err(failure))
+ return d
+
+ def fetch_flags(self, messages_asked, uid):
+ """
+ A fast method to fetch all flags, tricking just the
+ needed subset of the MIME interface that's needed to satisfy
+ a generic FLAGS query.
+
+ Given how LEAP Mail is supposed to work without local cache,
+ this query is going to be quite common, and also we expect
+ it to be in the form 1:* at the beginning of a session, so
+ it's not bad to fetch all the FLAGS docs at once.
+
+ :param messages_asked: IDs of the messages to retrieve information
+ about
+ :type messages_asked: MessageSet
+
+ :param uid: If 1, the IDs are UIDs. They are message sequence IDs
+ otherwise.
+ :type uid: int
+
+ :return: A tuple of two-tuples of message sequence numbers and
+ flagsPart, which is a only a partial implementation of
+ MessagePart.
+ :rtype: tuple
+ """
+ # is_sequence = True if uid == 0 else False
+ # XXX FIXME -----------------------------------------------------
+ # imap/tests, or muas like mutt, it will choke until we implement
+ # sequence numbers. This is an easy hack meanwhile.
+ is_sequence = False
+ # ---------------------------------------------------------------
+
+ if is_sequence:
+ raise NotImplementedError(
+ "FETCH FLAGS NOT IMPLEMENTED FOR MESSAGE SEQUENCE NUMBERS YET")
+
+ d = defer.Deferred()
+ reactor.callLater(0, self._do_fetch_flags, messages_asked, uid, d)
+ if PROFILE_CMD:
+ do_profile_cmd(d, "FETCH-ALL-FLAGS")
+ return d
+
+ def _do_fetch_flags(self, messages_asked, uid, d):
+ """
+ :param messages_asked: IDs of the messages to retrieve information
+ about
+ :type messages_asked: MessageSet
+
+ :param uid: If 1, the IDs are UIDs. They are message sequence IDs
+ otherwise.
+ :type uid: int
+ :param d: deferred whose callback will be called with result.
+ :type d: Deferred
+
+ :rtype: A generator that yields two-tuples of message sequence numbers
+ and flagsPart
+ """
+ class flagsPart(object):
+ def __init__(self, uid, flags):
+ self.uid = uid
+ self.flags = flags
+
+ def getUID(self):
+ return self.uid
+
+ def getFlags(self):
+ return map(str, self.flags)
+
+ def pack_flags(result):
+ _uid, _flags = result
+ return _uid, flagsPart(_uid, _flags)
+
+ def get_flags_for_seq(sequence):
+ d_all_flags = []
+ for msgid in sequence:
+ # TODO implement sequence numbers here too
+ d_flags_per_uid = self.collection.get_flags_by_uid(msgid)
+ d_flags_per_uid.addCallback(pack_flags)
+ d_all_flags.append(d_flags_per_uid)
+ gotflags = defer.gatherResults(d_all_flags)
+ gotflags.addCallback(get_uid_flag_generator)
+ return gotflags
+
+ def get_uid_flag_generator(result):
+ generator = (item for item in result)
+ d.callback(generator)
+
+ d_seq = self._get_messages_range(messages_asked, uid)
+ d_seq.addCallback(get_flags_for_seq)
+ return d_seq
+
+ @defer.inlineCallbacks
+ def fetch_headers(self, messages_asked, uid):
+ """
+ A fast method to fetch all headers, tricking just the
+ needed subset of the MIME interface that's needed to satisfy
+ a generic HEADERS query.
+
+ Given how LEAP Mail is supposed to work without local cache,
+ this query is going to be quite common, and also we expect
+ it to be in the form 1:* at the beginning of a session, so
+ **MAYBE** it's not too bad to fetch all the HEADERS docs at once.
+
+ :param messages_asked: IDs of the messages to retrieve information
+ about
+ :type messages_asked: MessageSet
+
+ :param uid: If true, the IDs are UIDs. They are message sequence IDs
+ otherwise.
+ :type uid: bool
+
+ :return: A tuple of two-tuples of message sequence numbers and
+ headersPart, which is a only a partial implementation of
+ MessagePart.
+ :rtype: tuple
+ """
+ # TODO implement sequences
+ is_sequence = True if uid == 0 else False
+ if is_sequence:
+ raise NotImplementedError(
+ "FETCH HEADERS NOT IMPLEMENTED FOR SEQUENCE NUMBER YET")
+
+ class headersPart(object):
+ def __init__(self, uid, headers):
+ self.uid = uid
+ self.headers = headers
+
+ def getUID(self):
+ return self.uid
+
+ def getHeaders(self, _):
+ return dict(
+ (str(key), str(value))
+ for key, value in
+ self.headers.items())
+
+ messages_asked = yield self._bound_seq(messages_asked, uid)
+ seq_messg = yield self._filter_msg_seq(messages_asked)
+
+ result = []
+ for msgid in seq_messg:
+ msg = yield self.collection.get_message_by_uid(msgid)
+ headers = headersPart(msgid, msg.get_headers())
+ result.append((msgid, headers))
+ defer.returnValue(iter(result))
+
+ def store(self, messages_asked, flags, mode, uid):
+ """
+ Sets the flags of one or more messages.
+
+ :param messages: The identifiers of the messages to set the flags
+ :type messages: A MessageSet object with the list of messages requested
+
+ :param flags: The flags to set, unset, or add.
+ :type flags: sequence of str
+
+ :param mode: If mode is -1, these flags should be removed from the
+ specified messages. If mode is 1, these flags should be
+ added to the specified messages. If mode is 0, all
+ existing flags should be cleared and these flags should be
+ added.
+ :type mode: -1, 0, or 1
+
+ :param uid: If true, the IDs specified in the query are UIDs;
+ otherwise they are message sequence IDs.
+ :type uid: bool
+
+ :return: A deferred, that will be called with a dict mapping message
+ sequence numbers to sequences of str representing the flags
+ set on the message after this operation has been performed.
+ :rtype: deferred
+
+ :raise ReadOnlyMailbox: Raised if this mailbox is not open for
+ read-write.
+ """
+ if not self.isWriteable():
+ log.msg('read only mailbox!')
+ raise imap4.ReadOnlyMailbox
+
+ d = defer.Deferred()
+ reactor.callLater(0, self._do_store, messages_asked, flags,
+ mode, uid, d)
+ if PROFILE_CMD:
+ do_profile_cmd(d, "STORE")
+
+ d.addCallback(self.collection.cb_signal_unread_to_ui)
+ d.addErrback(lambda f: log.err(f))
+ return d
+
+ def _do_store(self, messages_asked, flags, mode, uid, observer):
+ """
+ Helper method, invoke set_flags method in the IMAPMessageCollection.
+
+ See the documentation for the `store` method for the parameters.
+
+ :param observer: a deferred that will be called with the dictionary
+ mapping UIDs to flags after the operation has been
+ done.
+ :type observer: deferred
+ """
+ # TODO we should prevent client from setting Recent flag
+ get_msg_fun = self._get_message_fun(uid)
+ leap_assert(not isinstance(flags, basestring),
+ "flags cannot be a string")
+ flags = tuple(flags)
+
+ def set_flags_for_seq(sequence):
+ def return_result_dict(list_of_flags):
+ result = dict(zip(list(sequence), list_of_flags))
+ observer.callback(result)
+ return result
+
+ d_all_set = []
+ for msgid in sequence:
+ d = get_msg_fun(msgid)
+ d.addCallback(lambda msg: self.collection.update_flags(
+ msg, flags, mode))
+ d_all_set.append(d)
+ got_flags_setted = defer.gatherResults(d_all_set)
+ got_flags_setted.addCallback(return_result_dict)
+ return got_flags_setted
+
+ d_seq = self._get_messages_range(messages_asked, uid)
+ d_seq.addCallback(set_flags_for_seq)
+ return d_seq
+
+ # ISearchableMailbox
+
+ def search(self, query, uid):
+ """
+ Search for messages that meet the given query criteria.
+
+ Warning: this is half-baked, and it might give problems since
+ it offers the SearchableInterface.
+ We'll be implementing it asap.
+
+ :param query: The search criteria
+ :type query: list
+
+ :param uid: If true, the IDs specified in the query are UIDs;
+ otherwise they are message sequence IDs.
+ :type uid: bool
+
+ :return: A list of message sequence numbers or message UIDs which
+ match the search criteria or a C{Deferred} whose callback
+ will be invoked with such a list.
+ :rtype: C{list} or C{Deferred}
+ """
+ # TODO see if we can raise w/o interrupting flow
+ # :raise IllegalQueryError: Raised when query is not valid.
+ # example query:
+ # ['UNDELETED', 'HEADER', 'Message-ID',
+ # XXX fixme, does not exist
+ # '52D44F11.9060107@dev.bitmask.net']
+
+ # TODO hardcoding for now! -- we'll support generic queries later on
+ # but doing a quickfix for avoiding duplicate saves in the draft
+ # folder. # See issue #4209
+
+ if len(query) > 2:
+ if query[1] == 'HEADER' and query[2].lower() == "message-id":
+ msgid = str(query[3]).strip()
+ logger.debug("Searching for %s" % (msgid,))
+
+ d = self.collection.get_uid_from_msgid(str(msgid))
+ d.addCallback(lambda result: [result])
+ return d
+
+ # nothing implemented for any other query
+ logger.warning("Cannot process query: %s" % (query,))
+ return []
+
+ # IMessageCopier
+
+ def copy(self, message):
+ """
+ Copy the given message object into this mailbox.
+
+ :param message: an IMessage implementor
+ :type message: LeapMessage
+ :return: a deferred that will be fired with the message
+ uid when the copy succeed.
+ :rtype: Deferred
+ """
+ # if PROFILE_CMD:
+ # do_profile_cmd(d, "COPY")
+
+ # A better place for this would be the COPY/APPEND dispatcher
+ # in server.py, but qtreactor hangs when I do that, so this seems
+ # to work fine for now.
+ # d.addCallback(lambda r: self.reactor.callLater(0, self.notify_new))
+ # deferLater(self.reactor, 0, self._do_copy, message, d)
+ # return d
+
+ d = self.collection.copy_msg(message.message,
+ self.collection.mbox_uuid)
+ return d
+
+ # convenience fun
+
+ def deleteAllDocs(self):
+ """
+ Delete all docs in this mailbox
+ """
+ # FIXME not implemented
+ return self.collection.delete_all_docs()
+
+ def unset_recent_flags(self, uid_seq):
+ """
+ Unset Recent flag for a sequence of UIDs.
+ """
+ # FIXME not implemented
+ return self.collection.unset_recent_flags(uid_seq)
+
+ def __repr__(self):
+ """
+ Representation string for this mailbox.
+ """
+ return u"<IMAPMailbox: mbox '%s' (%s)>" % (
+ self.mbox_name, self.collection.count())
+
+
+_INBOX_RE = re.compile(INBOX_NAME, re.IGNORECASE)
+
+
+def normalize_mailbox(name):
+ """
+ Return a normalized representation of the mailbox ``name``.
+
+ This method ensures that an eventual initial 'inbox' part of a
+ mailbox name is made uppercase.
+
+ :param name: the name of the mailbox
+ :type name: unicode
+
+ :rtype: unicode
+ """
+ # XXX maybe it would make sense to normalize common folders too:
+ # trash, sent, drafts, etc...
+ if _INBOX_RE.match(name):
+ # ensure inital INBOX is uppercase
+ return INBOX_NAME + name[len(INBOX_NAME):]
+ return name
diff --git a/src/leap/bitmask/mail/imap/messages.py b/src/leap/bitmask/mail/imap/messages.py
new file mode 100644
index 0000000..d1c7b93
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/messages.py
@@ -0,0 +1,254 @@
+# -*- coding: utf-8 -*-
+# imap/messages.py
+# Copyright (C) 2013-2015 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+IMAPMessage implementation.
+"""
+import logging
+from twisted.mail import imap4
+from twisted.internet import defer
+from zope.interface import implements
+
+from leap.mail.utils import find_charset, CaseInsensitiveDict
+
+
+logger = logging.getLogger(__name__)
+
+# TODO
+# [ ] Add ref to incoming message during add_msg.
+
+
+class IMAPMessage(object):
+ """
+ The main representation of a message as seen by the IMAP Server.
+ This class implements the semantics specific to IMAP specification.
+ """
+ implements(imap4.IMessage)
+
+ def __init__(self, message, prefetch_body=True,
+ store=None, d=defer.Deferred()):
+ """
+ Get an IMAPMessage. A mail.Message is needed, since many of the methods
+ are proxied to that object.
+
+
+ If you do not need to prefetch the body of the message, you can set
+ `prefetch_body` to False, but the current imap server implementation
+ expect the getBodyFile method to return inmediately.
+
+ When the prefetch_body option is used, a deferred is also expected as a
+ parameter, and this will fire when the deferred initialization has
+ taken place, with this instance of IMAPMessage as a parameter.
+
+ :param message: the abstract message
+ :type message: mail.Message
+ :param prefetch_body: Whether to prefetch the content doc for the body.
+ :type prefetch_body: bool
+ :param store: an instance of soledad, or anything that behaves like it.
+ :param d: an optional deferred, that will be fired with the instance of
+ the IMAPMessage being initialized
+ :type d: defer.Deferred
+ """
+ # TODO substitute the use of the deferred initialization by a factory
+ # function, maybe.
+
+ self.message = message
+ self.__body_fd = None
+ self.store = store
+ if prefetch_body:
+ gotbody = self.__prefetch_body_file()
+ gotbody.addCallback(lambda _: d.callback(self))
+
+ # IMessage implementation
+
+ def getUID(self):
+ """
+ Retrieve the unique identifier associated with this Message.
+
+ :return: uid for this message
+ :rtype: int
+ """
+ return self.message.get_uid()
+
+ def getFlags(self):
+ """
+ Retrieve the flags associated with this Message.
+
+ :return: The flags, represented as strings
+ :rtype: tuple
+ """
+ return self.message.get_flags()
+
+ def getInternalDate(self):
+ """
+ Retrieve the date internally associated with this message
+
+ According to the spec, this is NOT the date and time in the
+ RFC-822 header, but rather a date and time that reflects when the
+ message was received.
+
+ * In SMTP, date and time of final delivery.
+ * In COPY, internal date/time of the source message.
+ * In APPEND, date/time specified.
+
+ :return: An RFC822-formatted date string.
+ :rtype: str
+ """
+ return self.message.get_internal_date()
+
+ #
+ # IMessagePart
+ #
+
+ def getBodyFile(self, store=None):
+ """
+ Retrieve a file object containing only the body of this message.
+
+ :return: file-like object opened for reading
+ :rtype: a deferred that will fire with a StringIO object.
+ """
+ if self.__body_fd is not None:
+ fd = self.__body_fd
+ fd.seek(0)
+ return fd
+
+ if store is None:
+ store = self.store
+ return self.message.get_body_file(store)
+
+ def getSize(self):
+ """
+ Return the total size, in octets, of this message.
+
+ :return: size of the message, in octets
+ :rtype: int
+ """
+ return self.message.get_size()
+
+ def getHeaders(self, negate, *names):
+ """
+ Retrieve a group of message headers.
+
+ :param names: The names of the headers to retrieve or omit.
+ :type names: tuple of str
+
+ :param negate: If True, indicates that the headers listed in names
+ should be omitted from the return value, rather
+ than included.
+ :type negate: bool
+
+ :return: A mapping of header field names to header field values
+ :rtype: dict
+ """
+ headers = self.message.get_headers()
+ return _format_headers(headers, negate, *names)
+
+ def isMultipart(self):
+ """
+ Return True if this message is multipart.
+ """
+ return self.message.is_multipart()
+
+ def getSubPart(self, part):
+ """
+ Retrieve a MIME submessage
+
+ :type part: C{int}
+ :param part: The number of the part to retrieve, indexed from 0.
+ :raise IndexError: Raised if the specified part does not exist.
+ :raise TypeError: Raised if this message is not multipart.
+ :rtype: Any object implementing C{IMessagePart}.
+ :return: The specified sub-part.
+ """
+ subpart = self.message.get_subpart(part + 1)
+ return IMAPMessagePart(subpart)
+
+ def __prefetch_body_file(self):
+ def assign_body_fd(fd):
+ self.__body_fd = fd
+ return fd
+ d = self.getBodyFile()
+ d.addCallback(assign_body_fd)
+ return d
+
+
+class IMAPMessagePart(object):
+
+ def __init__(self, message_part):
+ self.message_part = message_part
+
+ def getBodyFile(self, store=None):
+ return self.message_part.get_body_file()
+
+ def getSize(self):
+ return self.message_part.get_size()
+
+ def getHeaders(self, negate, *names):
+ headers = self.message_part.get_headers()
+ return _format_headers(headers, negate, *names)
+
+ def isMultipart(self):
+ return self.message_part.is_multipart()
+
+ def getSubPart(self, part):
+ subpart = self.message_part.get_subpart(part + 1)
+ return IMAPMessagePart(subpart)
+
+
+def _format_headers(headers, negate, *names):
+ # current server impl. expects content-type to be present, so if for
+ # some reason we do not have headers, we have to return at least that
+ # one
+ if not headers:
+ logger.warning("No headers found")
+ return {str('content-type'): str('')}
+
+ names = map(lambda s: s.upper(), names)
+
+ if negate:
+ def cond(key):
+ return key.upper() not in names
+ else:
+ def cond(key):
+ return key.upper() in names
+
+ if isinstance(headers, list):
+ headers = dict(headers)
+
+ # default to most likely standard
+ charset = find_charset(headers, "utf-8")
+
+ # We will return a copy of the headers dictionary that
+ # will allow case-insensitive lookups. In some parts of the twisted imap
+ # server code the keys are expected to be in lower case, and in this way
+ # we avoid having to convert them.
+
+ _headers = CaseInsensitiveDict()
+ for key, value in headers.items():
+ if not isinstance(key, str):
+ key = key.encode(charset, 'replace')
+ if not isinstance(value, str):
+ value = value.encode(charset, 'replace')
+
+ if value.endswith(";"):
+ # bastards
+ value = value[:-1]
+
+ # filter original dict by negate-condition
+ if cond(key):
+ _headers[key] = value
+
+ return _headers
diff --git a/src/leap/bitmask/mail/imap/server.py b/src/leap/bitmask/mail/imap/server.py
new file mode 100644
index 0000000..5a63af0
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/server.py
@@ -0,0 +1,693 @@
+# -*- coding: utf-8 -*-
+# server.py
+# Copyright (C) 2014 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+LEAP IMAP4 Server Implementation.
+"""
+import StringIO
+from copy import copy
+
+from twisted.internet.defer import maybeDeferred
+from twisted.mail import imap4
+from twisted.python import log
+
+# imports for LITERAL+ patch
+from twisted.internet import defer, interfaces
+from twisted.mail.imap4 import IllegalClientResponse
+from twisted.mail.imap4 import LiteralString, LiteralFile
+
+from leap.common.events import emit_async, catalog
+
+
+def _getContentType(msg):
+ """
+ Return a two-tuple of the main and subtype of the given message.
+ """
+ attrs = None
+ mm = msg.getHeaders(False, 'content-type').get('content-type', None)
+ if mm:
+ mm = ''.join(mm.splitlines())
+ mimetype = mm.split(';')
+ if mimetype:
+ type = mimetype[0].split('/', 1)
+ if len(type) == 1:
+ major = type[0]
+ minor = None
+ elif len(type) == 2:
+ major, minor = type
+ else:
+ major = minor = None
+ # XXX patched ---------------------------------------------
+ attrs = dict(x.strip().split('=', 1) for x in mimetype[1:])
+ # XXX patched ---------------------------------------------
+ else:
+ major = minor = None
+ else:
+ major = minor = None
+ return major, minor, attrs
+
+# Monkey-patch _getContentType to avoid bug that passes lower-case boundary in
+# BODYSTRUCTURE response.
+imap4._getContentType = _getContentType
+
+
+class LEAPIMAPServer(imap4.IMAP4Server):
+ """
+ An IMAP4 Server with a LEAP Storage Backend.
+ """
+
+ #############################################################
+ #
+ # Twisted imap4 patch to workaround bad mime rendering in TB.
+ # See https://leap.se/code/issues/6773
+ # and https://bugzilla.mozilla.org/show_bug.cgi?id=149771
+ # Still unclear if this is a thunderbird bug.
+ # TODO send this patch upstream
+ #
+ #############################################################
+
+ def spew_body(self, part, id, msg, _w=None, _f=None):
+ if _w is None:
+ _w = self.transport.write
+ for p in part.part:
+ if msg.isMultipart():
+ msg = msg.getSubPart(p)
+ elif p > 0:
+ # Non-multipart messages have an implicit first part but no
+ # other parts - reject any request for any other part.
+ raise TypeError("Requested subpart of non-multipart message")
+
+ if part.header:
+ hdrs = msg.getHeaders(part.header.negate, *part.header.fields)
+ hdrs = imap4._formatHeaders(hdrs)
+ # PATCHED ##########################################
+ _w(str(part) + ' ' + imap4._literal(hdrs + "\r\n"))
+ # PATCHED ##########################################
+ elif part.text:
+ _w(str(part) + ' ')
+ _f()
+ return imap4.FileProducer(
+ msg.getBodyFile()
+ ).beginProducing(self.transport)
+ elif part.mime:
+ hdrs = imap4._formatHeaders(msg.getHeaders(True))
+
+ # PATCHED ##########################################
+ _w(str(part) + ' ' + imap4._literal(hdrs + "\r\n"))
+ # END PATCHED ######################################
+
+ elif part.empty:
+ _w(str(part) + ' ')
+ _f()
+ if part.part:
+ # PATCHED #############################################
+ # implement partial FETCH
+ # TODO implement boundary checks
+ # TODO see if there's a more efficient way, without
+ # copying the original content into a new buffer.
+ fd = msg.getBodyFile()
+ begin = getattr(part, "partialBegin", None)
+ _len = getattr(part, "partialLength", None)
+ if begin is not None and _len is not None:
+ _fd = StringIO.StringIO()
+ fd.seek(part.partialBegin)
+ _fd.write(fd.read(part.partialLength))
+ _fd.seek(0)
+ else:
+ _fd = fd
+ return imap4.FileProducer(
+ _fd
+ # END PATCHED #########################3
+ ).beginProducing(self.transport)
+ else:
+ mf = imap4.IMessageFile(msg, None)
+ if mf is not None:
+ return imap4.FileProducer(
+ mf.open()).beginProducing(self.transport)
+ return imap4.MessageProducer(
+ msg, None, self._scheduler).beginProducing(self.transport)
+
+ else:
+ _w('BODY ' +
+ imap4.collapseNestedLists([imap4.getBodyStructure(msg)]))
+
+ ##################################################################
+ #
+ # END Twisted imap4 patch to workaround bad mime rendering in TB.
+ # #6773
+ #
+ ##################################################################
+
+ def lineReceived(self, line):
+ """
+ Attempt to parse a single line from the server.
+
+ :param line: the line from the server, without the line delimiter.
+ :type line: str
+ """
+ if "login" in line.lower():
+ # avoid to log the pass, even though we are using a dummy auth
+ # by now.
+ msg = line[:7] + " [...]"
+ else:
+ msg = copy(line)
+ log.msg('rcv (%s): %s' % (self.state, msg))
+ imap4.IMAP4Server.lineReceived(self, line)
+
+ def close_server_connection(self):
+ """
+ Send a BYE command so that the MUA at least knows that we're closing
+ the connection.
+ """
+ self.sendLine(
+ '* BYE LEAP IMAP Proxy is shutting down; '
+ 'so long and thanks for all the fish')
+ self.transport.loseConnection()
+ if self.mbox:
+ self.mbox.removeListener(self)
+ self.mbox = None
+ self.state = 'unauth'
+
+ def do_FETCH(self, tag, messages, query, uid=0):
+ """
+ Overwritten fetch dispatcher to use the fast fetch_flags
+ method
+ """
+ if not query:
+ self.sendPositiveResponse(tag, 'FETCH complete')
+ return
+
+ cbFetch = self._IMAP4Server__cbFetch
+ ebFetch = self._IMAP4Server__ebFetch
+
+ if len(query) == 1 and str(query[0]) == "flags":
+ self._oldTimeout = self.setTimeout(None)
+ # no need to call iter, we get a generator
+ maybeDeferred(
+ self.mbox.fetch_flags, messages, uid=uid
+ ).addCallback(
+ cbFetch, tag, query, uid
+ ).addErrback(ebFetch, tag)
+
+ elif len(query) == 1 and str(query[0]) == "rfc822.header":
+ self._oldTimeout = self.setTimeout(None)
+ # no need to call iter, we get a generator
+ maybeDeferred(
+ self.mbox.fetch_headers, messages, uid=uid
+ ).addCallback(
+ cbFetch, tag, query, uid
+ ).addErrback(ebFetch, tag)
+ else:
+ self._oldTimeout = self.setTimeout(None)
+ # no need to call iter, we get a generator
+ maybeDeferred(
+ self.mbox.fetch, messages, uid=uid
+ ).addCallback(
+ cbFetch, tag, query, uid
+ ).addErrback(
+ ebFetch, tag)
+
+ select_FETCH = (do_FETCH, imap4.IMAP4Server.arg_seqset,
+ imap4.IMAP4Server.arg_fetchatt)
+
+ def _cbSelectWork(self, mbox, cmdName, tag):
+ """
+ Callback for selectWork
+
+ * patched to avoid conformance errors due to incomplete UIDVALIDITY
+ line.
+ * patched to accept deferreds for messagecount and recent count
+ """
+ if mbox is None:
+ self.sendNegativeResponse(tag, 'No such mailbox')
+ return
+ if '\\noselect' in [s.lower() for s in mbox.getFlags()]:
+ self.sendNegativeResponse(tag, 'Mailbox cannot be selected')
+ return
+
+ d1 = defer.maybeDeferred(mbox.getMessageCount)
+ d2 = defer.maybeDeferred(mbox.getRecentCount)
+ return defer.gatherResults([d1, d2]).addCallback(
+ self.__cbSelectWork, mbox, cmdName, tag)
+
+ def __cbSelectWork(self, ((msg_count, recent_count)), mbox, cmdName, tag):
+ flags = mbox.getFlags()
+ self.sendUntaggedResponse('FLAGS (%s)' % ' '.join(flags))
+
+ # Patched -------------------------------------------------------
+ # accept deferreds for the count
+ self.sendUntaggedResponse(str(msg_count) + ' EXISTS')
+ self.sendUntaggedResponse(str(recent_count) + ' RECENT')
+ # ----------------------------------------------------------------
+
+ # Patched -------------------------------------------------------
+ # imaptest was complaining about the incomplete line, we're adding
+ # "UIDs valid" here.
+ self.sendPositiveResponse(
+ None, '[UIDVALIDITY %d] UIDs valid' % mbox.getUIDValidity())
+ # ----------------------------------------------------------------
+
+ s = mbox.isWriteable() and 'READ-WRITE' or 'READ-ONLY'
+ mbox.addListener(self)
+ self.sendPositiveResponse(tag, '[%s] %s successful' % (s, cmdName))
+ self.state = 'select'
+ self.mbox = mbox
+
+ def checkpoint(self):
+ """
+ Called when the client issues a CHECK command.
+
+ This should perform any checkpoint operations required by the server.
+ It may be a long running operation, but may not block. If it returns
+ a deferred, the client will only be informed of success (or failure)
+ when the deferred's callback (or errback) is invoked.
+ """
+ # TODO implement a collection of ongoing deferreds?
+ return None
+
+ #############################################################
+ #
+ # Twisted imap4 patch to support LITERAL+ extension
+ # TODO send this patch upstream asap!
+ #
+ #############################################################
+
+ def capabilities(self):
+ cap = {'AUTH': self.challengers.keys()}
+ if self.ctx and self.canStartTLS:
+ t = self.transport
+ ti = interfaces.ISSLTransport
+ if not self.startedTLS and ti(t, None) is None:
+ cap['LOGINDISABLED'] = None
+ cap['STARTTLS'] = None
+ cap['NAMESPACE'] = None
+ cap['IDLE'] = None
+ # patched ############
+ cap['LITERAL+'] = None
+ ######################
+ return cap
+
+ def _stringLiteral(self, size, literal_plus=False):
+ if size > self._literalStringLimit:
+ raise IllegalClientResponse(
+ "Literal too long! I accept at most %d octets" %
+ (self._literalStringLimit,))
+ d = defer.Deferred()
+ self.parseState = 'pending'
+ self._pendingLiteral = LiteralString(size, d)
+ # Patched ###########################################################
+ if not literal_plus:
+ self.sendContinuationRequest('Ready for %d octets of text' % size)
+ #####################################################################
+ self.setRawMode()
+ return d
+
+ def _fileLiteral(self, size, literal_plus=False):
+ d = defer.Deferred()
+ self.parseState = 'pending'
+ self._pendingLiteral = LiteralFile(size, d)
+ if not literal_plus:
+ self.sendContinuationRequest('Ready for %d octets of data' % size)
+ self.setRawMode()
+ return d
+
+ def arg_astring(self, line):
+ """
+ Parse an astring from the line, return (arg, rest), possibly
+ via a deferred (to handle literals)
+ """
+ line = line.strip()
+ if not line:
+ raise IllegalClientResponse("Missing argument")
+ d = None
+ arg, rest = None, None
+ if line[0] == '"':
+ try:
+ spam, arg, rest = line.split('"', 2)
+ rest = rest[1:] # Strip space
+ except ValueError:
+ raise IllegalClientResponse("Unmatched quotes")
+ elif line[0] == '{':
+ # literal
+ if line[-1] != '}':
+ raise IllegalClientResponse("Malformed literal")
+
+ # Patched ################
+ if line[-2] == "+":
+ literalPlus = True
+ size_end = -2
+ else:
+ literalPlus = False
+ size_end = -1
+
+ try:
+ size = int(line[1:size_end])
+ except ValueError:
+ raise IllegalClientResponse(
+ "Bad literal size: " + line[1:size_end])
+ d = self._stringLiteral(size, literalPlus)
+ ##########################
+ else:
+ arg = line.split(' ', 1)
+ if len(arg) == 1:
+ arg.append('')
+ arg, rest = arg
+ return d or (arg, rest)
+
+ def arg_literal(self, line):
+ """
+ Parse a literal from the line
+ """
+ if not line:
+ raise IllegalClientResponse("Missing argument")
+
+ if line[0] != '{':
+ raise IllegalClientResponse("Missing literal")
+
+ if line[-1] != '}':
+ raise IllegalClientResponse("Malformed literal")
+
+ # Patched ##################
+ if line[-2] == "+":
+ literalPlus = True
+ size_end = -2
+ else:
+ literalPlus = False
+ size_end = -1
+
+ try:
+ size = int(line[1:size_end])
+ except ValueError:
+ raise IllegalClientResponse(
+ "Bad literal size: " + line[1:size_end])
+
+ return self._fileLiteral(size, literalPlus)
+ #############################
+
+ # --------------------------------- isSubscribed patch
+ # TODO -- send patch upstream.
+ # There is a bug in twisted implementation:
+ # in cbListWork, it's assumed that account.isSubscribed IS a callable,
+ # although in the interface documentation it's stated that it can be
+ # a deferred.
+
+ def _listWork(self, tag, ref, mbox, sub, cmdName):
+ mbox = self._parseMbox(mbox)
+ mailboxes = maybeDeferred(self.account.listMailboxes, ref, mbox)
+ mailboxes.addCallback(self._cbSubscribed)
+ mailboxes.addCallback(
+ self._cbListWork, tag, sub, cmdName,
+ ).addErrback(self._ebListWork, tag)
+
+ def _cbSubscribed(self, mailboxes):
+ subscribed = [
+ maybeDeferred(self.account.isSubscribed, name)
+ for (name, box) in mailboxes]
+
+ def get_mailboxes_and_subs(result):
+ subscribed = [i[0] for i, yes in zip(mailboxes, result) if yes]
+ return mailboxes, subscribed
+
+ d = defer.gatherResults(subscribed)
+ d.addCallback(get_mailboxes_and_subs)
+ return d
+
+ def _cbListWork(self, mailboxes_subscribed, tag, sub, cmdName):
+ mailboxes, subscribed = mailboxes_subscribed
+
+ for (name, box) in mailboxes:
+ if not sub or name in subscribed:
+ flags = box.getFlags()
+ delim = box.getHierarchicalDelimiter()
+ resp = (imap4.DontQuoteMe(cmdName),
+ map(imap4.DontQuoteMe, flags),
+ delim, name.encode('imap4-utf-7'))
+ self.sendUntaggedResponse(
+ imap4.collapseNestedLists(resp))
+ self.sendPositiveResponse(tag, '%s completed' % (cmdName,))
+ # -------------------- end isSubscribed patch -----------
+
+ # TODO subscribe method had also to be changed to accomodate deferred
+ def do_SUBSCRIBE(self, tag, name):
+ name = self._parseMbox(name)
+
+ def _subscribeCb(_):
+ self.sendPositiveResponse(tag, 'Subscribed')
+
+ def _subscribeEb(failure):
+ m = failure.value
+ log.err()
+ if failure.check(imap4.MailboxException):
+ self.sendNegativeResponse(tag, str(m))
+ else:
+ self.sendBadResponse(
+ tag,
+ "Server error encountered while subscribing to mailbox")
+
+ d = self.account.subscribe(name)
+ d.addCallbacks(_subscribeCb, _subscribeEb)
+ return d
+
+ auth_SUBSCRIBE = (do_SUBSCRIBE, arg_astring)
+ select_SUBSCRIBE = auth_SUBSCRIBE
+
+ def do_UNSUBSCRIBE(self, tag, name):
+ # unsubscribe method had also to be changed to accomodate
+ # deferred
+ name = self._parseMbox(name)
+
+ def _unsubscribeCb(_):
+ self.sendPositiveResponse(tag, 'Unsubscribed')
+
+ def _unsubscribeEb(failure):
+ m = failure.value
+ log.err()
+ if failure.check(imap4.MailboxException):
+ self.sendNegativeResponse(tag, str(m))
+ else:
+ self.sendBadResponse(
+ tag,
+ "Server error encountered while unsubscribing "
+ "from mailbox")
+
+ d = self.account.unsubscribe(name)
+ d.addCallbacks(_unsubscribeCb, _unsubscribeEb)
+ return d
+
+ auth_UNSUBSCRIBE = (do_UNSUBSCRIBE, arg_astring)
+ select_UNSUBSCRIBE = auth_UNSUBSCRIBE
+
+ def do_RENAME(self, tag, oldname, newname):
+ oldname, newname = [self._parseMbox(n) for n in oldname, newname]
+ if oldname.lower() == 'inbox' or newname.lower() == 'inbox':
+ self.sendNegativeResponse(
+ tag,
+ 'You cannot rename the inbox, or '
+ 'rename another mailbox to inbox.')
+ return
+
+ def _renameCb(_):
+ self.sendPositiveResponse(tag, 'Mailbox renamed')
+
+ def _renameEb(failure):
+ m = failure.value
+ if failure.check(TypeError):
+ self.sendBadResponse(tag, 'Invalid command syntax')
+ elif failure.check(imap4.MailboxException):
+ self.sendNegativeResponse(tag, str(m))
+ else:
+ log.err()
+ self.sendBadResponse(
+ tag,
+ "Server error encountered while "
+ "renaming mailbox")
+
+ d = self.account.rename(oldname, newname)
+ d.addCallbacks(_renameCb, _renameEb)
+ return d
+
+ auth_RENAME = (do_RENAME, arg_astring, arg_astring)
+ select_RENAME = auth_RENAME
+
+ def do_CREATE(self, tag, name):
+ name = self._parseMbox(name)
+
+ def _createCb(result):
+ if result:
+ self.sendPositiveResponse(tag, 'Mailbox created')
+ else:
+ self.sendNegativeResponse(tag, 'Mailbox not created')
+
+ def _createEb(failure):
+ c = failure.value
+ if failure.check(imap4.MailboxException):
+ self.sendNegativeResponse(tag, str(c))
+ else:
+ log.err()
+ self.sendBadResponse(
+ tag, "Server error encountered while creating mailbox")
+
+ d = self.account.create(name)
+ d.addCallbacks(_createCb, _createEb)
+ return d
+
+ auth_CREATE = (do_CREATE, arg_astring)
+ select_CREATE = auth_CREATE
+
+ def do_DELETE(self, tag, name):
+ name = self._parseMbox(name)
+ if name.lower() == 'inbox':
+ self.sendNegativeResponse(tag, 'You cannot delete the inbox')
+ return
+
+ def _deleteCb(result):
+ self.sendPositiveResponse(tag, 'Mailbox deleted')
+
+ def _deleteEb(failure):
+ m = failure.value
+ if failure.check(imap4.MailboxException):
+ self.sendNegativeResponse(tag, str(m))
+ else:
+ print "SERVER: other error"
+ log.err()
+ self.sendBadResponse(
+ tag,
+ "Server error encountered while deleting mailbox")
+
+ d = self.account.delete(name)
+ d.addCallbacks(_deleteCb, _deleteEb)
+ return d
+
+ auth_DELETE = (do_DELETE, arg_astring)
+ select_DELETE = auth_DELETE
+
+ # -----------------------------------------------------------------------
+ # Patched just to allow __cbAppend to receive a deferred from messageCount
+ # TODO format and send upstream.
+ def do_APPEND(self, tag, mailbox, flags, date, message):
+ mailbox = self._parseMbox(mailbox)
+ maybeDeferred(self.account.select, mailbox).addCallback(
+ self._cbAppendGotMailbox, tag, flags, date, message).addErrback(
+ self._ebAppendGotMailbox, tag)
+
+ def __ebAppend(self, failure, tag):
+ self.sendBadResponse(tag, 'APPEND failed: ' + str(failure.value))
+
+ def _cbAppendGotMailbox(self, mbox, tag, flags, date, message):
+ if not mbox:
+ self.sendNegativeResponse(tag, '[TRYCREATE] No such mailbox')
+ return
+
+ d = mbox.addMessage(message, flags, date)
+ d.addCallback(self.__cbAppend, tag, mbox)
+ d.addErrback(self.__ebAppend, tag)
+
+ def _ebAppendGotMailbox(self, failure, tag):
+ self.sendBadResponse(
+ tag, "Server error encountered while opening mailbox.")
+ log.err(failure)
+
+ def __cbAppend(self, result, tag, mbox):
+
+ # XXX patched ---------------------------------
+ def send_response(count):
+ self.sendUntaggedResponse('%d EXISTS' % count)
+ self.sendPositiveResponse(tag, 'APPEND complete')
+
+ d = mbox.getMessageCount()
+ d.addCallback(send_response)
+ return d
+ # XXX patched ---------------------------------
+ # -----------------------------------------------------------------------
+
+ auth_APPEND = (do_APPEND, arg_astring, imap4.IMAP4Server.opt_plist,
+ imap4.IMAP4Server.opt_datetime, arg_literal)
+ select_APPEND = auth_APPEND
+
+ # Need to override the command table after patching
+ # arg_astring and arg_literal, except on the methods that we are already
+ # overriding.
+
+ # TODO --------------------------------------------
+ # Check if we really need to override these
+ # methods, or we can monkeypatch.
+ # do_DELETE = imap4.IMAP4Server.do_DELETE
+ # do_CREATE = imap4.IMAP4Server.do_CREATE
+ # do_RENAME = imap4.IMAP4Server.do_RENAME
+ # do_SUBSCRIBE = imap4.IMAP4Server.do_SUBSCRIBE
+ # do_UNSUBSCRIBE = imap4.IMAP4Server.do_UNSUBSCRIBE
+ # do_APPEND = imap4.IMAP4Server.do_APPEND
+ # -------------------------------------------------
+ do_LOGIN = imap4.IMAP4Server.do_LOGIN
+ do_STATUS = imap4.IMAP4Server.do_STATUS
+ do_COPY = imap4.IMAP4Server.do_COPY
+
+ _selectWork = imap4.IMAP4Server._selectWork
+
+ arg_plist = imap4.IMAP4Server.arg_plist
+ arg_seqset = imap4.IMAP4Server.arg_seqset
+ opt_plist = imap4.IMAP4Server.opt_plist
+ opt_datetime = imap4.IMAP4Server.opt_datetime
+
+ unauth_LOGIN = (do_LOGIN, arg_astring, arg_astring)
+
+ auth_SELECT = (_selectWork, arg_astring, 1, 'SELECT')
+ select_SELECT = auth_SELECT
+
+ auth_CREATE = (do_CREATE, arg_astring)
+ select_CREATE = auth_CREATE
+
+ auth_EXAMINE = (_selectWork, arg_astring, 0, 'EXAMINE')
+ select_EXAMINE = auth_EXAMINE
+
+ # TODO -----------------------------------------------
+ # re-add if we stop overriding DELETE
+ # auth_DELETE = (do_DELETE, arg_astring)
+ # select_DELETE = auth_DELETE
+ # auth_APPEND = (do_APPEND, arg_astring, opt_plist, opt_datetime,
+ # arg_literal)
+ # select_APPEND = auth_APPEND
+
+ # ----------------------------------------------------
+
+ auth_RENAME = (do_RENAME, arg_astring, arg_astring)
+ select_RENAME = auth_RENAME
+
+ auth_SUBSCRIBE = (do_SUBSCRIBE, arg_astring)
+ select_SUBSCRIBE = auth_SUBSCRIBE
+
+ auth_UNSUBSCRIBE = (do_UNSUBSCRIBE, arg_astring)
+ select_UNSUBSCRIBE = auth_UNSUBSCRIBE
+
+ auth_LIST = (_listWork, arg_astring, arg_astring, 0, 'LIST')
+ select_LIST = auth_LIST
+
+ auth_LSUB = (_listWork, arg_astring, arg_astring, 1, 'LSUB')
+ select_LSUB = auth_LSUB
+
+ auth_STATUS = (do_STATUS, arg_astring, arg_plist)
+ select_STATUS = auth_STATUS
+
+ select_COPY = (do_COPY, arg_seqset, arg_astring)
+
+ #############################################################
+ # END of Twisted imap4 patch to support LITERAL+ extension
+ #############################################################
+
+ def authenticateLogin(self, user, passwd):
+ result = imap4.IMAP4Server.authenticateLogin(self, user, passwd)
+ emit_async(catalog.IMAP_CLIENT_LOGIN, str(user))
+ return result
diff --git a/src/leap/bitmask/mail/imap/service/README.rst b/src/leap/bitmask/mail/imap/service/README.rst
new file mode 100644
index 0000000..2cca9b3
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/service/README.rst
@@ -0,0 +1,39 @@
+testing the service
+===================
+
+Run the twisted service::
+
+ twistd -n -y imap-server.tac
+
+And use offlineimap for tests::
+
+ offlineimap -c LEAPofflineimapRC-tests
+
+minimal offlineimap configuration
+---------------------------------
+
+[general]
+accounts = leap-local
+
+[Account leap-local]
+localrepository = LocalLeap
+remoterepository = RemoteLeap
+
+[Repository LocalLeap]
+type = Maildir
+localfolders = ~/LEAPMail/Mail
+
+[Repository RemoteLeap]
+type = IMAP
+ssl = no
+remotehost = localhost
+remoteport = 9930
+remoteuser = user
+remotepass = pass
+
+debugging
+---------
+
+Use ngrep to obtain logs of the sequences::
+
+ sudo ngrep -d lo -W byline port 9930
diff --git a/src/leap/bitmask/mail/imap/service/__init__.py b/src/leap/bitmask/mail/imap/service/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/service/__init__.py
diff --git a/src/leap/bitmask/mail/imap/service/imap-server.tac b/src/leap/bitmask/mail/imap/service/imap-server.tac
new file mode 100644
index 0000000..c4d602d
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/service/imap-server.tac
@@ -0,0 +1,145 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# imap-server.tac
+# Copyright (C) 2013,2014 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+TAC file for initialization of the imap service using twistd.
+
+Use this for debugging and testing the imap server using a native reactor.
+
+For now, and for debugging/testing purposes, you need
+to pass a config file with the following structure:
+
+[leap_mail]
+userid = 'user@provider'
+uuid = 'deadbeefdeadabad'
+passwd = 'supersecret' # optional, will get prompted if not found.
+"""
+
+# TODO -- this .tac file should be deprecated in favor of bitmask.core.bitmaskd
+
+import ConfigParser
+import getpass
+import os
+import sys
+
+from leap.keymanager import KeyManager
+from leap.mail.imap.service import imap
+from leap.soledad.client import Soledad
+
+from twisted.application import service, internet
+
+
+# TODO should get this initializers from some authoritative mocked source
+# We might want to put them the soledad itself.
+
+def initialize_soledad(uuid, email, passwd,
+ secrets, localdb,
+ gnupg_home, tempdir):
+ """
+ Initializes soledad by hand
+
+ :param email: ID for the user
+ :param gnupg_home: path to home used by gnupg
+ :param tempdir: path to temporal dir
+ :rtype: Soledad instance
+ """
+ server_url = "http://provider"
+ cert_file = ""
+
+ soledad = Soledad(
+ uuid,
+ passwd,
+ secrets,
+ localdb,
+ server_url,
+ cert_file,
+ syncable=False)
+
+ return soledad
+
+######################################################################
+# Remember to set your config files, see module documentation above!
+######################################################################
+
+print "[+] Running LEAP IMAP Service"
+
+
+bmconf = os.environ.get("LEAP_MAIL_CONFIG", "")
+if not bmconf:
+ print ("[-] Please set LEAP_MAIL_CONFIG environment variable "
+ "pointing to your config.")
+ sys.exit(1)
+
+SECTION = "leap_mail"
+cp = ConfigParser.ConfigParser()
+cp.read(bmconf)
+
+userid = cp.get(SECTION, "userid")
+uuid = cp.get(SECTION, "uuid")
+passwd = unicode(cp.get(SECTION, "passwd"))
+
+# XXX get this right from the environment variable !!!
+port = 1984
+
+if not userid or not uuid:
+ print "[-] Config file missing userid or uuid field"
+ sys.exit(1)
+
+if not passwd:
+ passwd = unicode(getpass.getpass("Soledad passphrase: "))
+
+
+secrets = os.path.expanduser("~/.config/leap/soledad/%s.secret" % (uuid,))
+localdb = os.path.expanduser("~/.config/leap/soledad/%s.db" % (uuid,))
+
+# XXX Is this really used? Should point it to user var dirs defined in xdg?
+gnupg_home = "/tmp/"
+tempdir = "/tmp/"
+
+###################################################
+
+# Ad-hoc soledad/keymanager initialization.
+
+print "[~] user:", userid
+soledad = initialize_soledad(uuid, userid, passwd, secrets,
+ localdb, gnupg_home, tempdir, userid=userid)
+km_args = (userid, "https://localhost", soledad)
+km_kwargs = {
+ "token": "",
+ "ca_cert_path": "",
+ "api_uri": "",
+ "api_version": "",
+ "uid": uuid,
+ "gpgbinary": "/usr/bin/gpg"
+}
+keymanager = KeyManager(*km_args, **km_kwargs)
+
+##################################################
+
+# Ok, let's expose the application object for the twistd application
+# framework to pick up from here...
+
+
+def getIMAPService():
+ soledad_sessions = {userid: soledad}
+ factory = imap.LeapIMAPFactory(soledad_sessions)
+ return internet.TCPServer(port, factory, interface="localhost")
+
+
+application = service.Application("LEAP IMAP Application")
+service = getIMAPService()
+service.setServiceParent(application)
diff --git a/src/leap/bitmask/mail/imap/service/imap.py b/src/leap/bitmask/mail/imap/service/imap.py
new file mode 100644
index 0000000..4663854
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/service/imap.py
@@ -0,0 +1,208 @@
+# -*- coding: utf-8 -*-
+# imap.py
+# Copyright (C) 2013-2015 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+IMAP Service Initialization.
+"""
+import logging
+import os
+
+from collections import defaultdict
+
+from twisted.cred.portal import Portal, IRealm
+from twisted.mail.imap4 import IAccount
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.internet.error import CannotListenError
+from twisted.internet.protocol import ServerFactory
+from twisted.python import log
+from zope.interface import implementer
+
+from leap.common.events import emit_async, catalog
+from leap.mail.cred import LocalSoledadTokenChecker
+from leap.mail.imap.account import IMAPAccount
+from leap.mail.imap.server import LEAPIMAPServer
+
+# TODO: leave only an implementor of IService in here
+
+logger = logging.getLogger(__name__)
+
+DO_MANHOLE = os.environ.get("LEAP_MAIL_MANHOLE", None)
+if DO_MANHOLE:
+ from leap.mail.imap.service import manhole
+
+# The default port in which imap service will run
+
+IMAP_PORT = 1984
+
+#
+# Credentials Handling
+#
+
+
+@implementer(IRealm)
+class LocalSoledadIMAPRealm(object):
+
+ _encoding = 'utf-8'
+
+ def __init__(self, soledad_sessions):
+ """
+ :param soledad_sessions: a dict-like object, containing instances
+ of a Store (soledad instances), indexed by
+ userid.
+ """
+ self._soledad_sessions = soledad_sessions
+
+ def requestAvatar(self, avatarId, mind, *interfaces):
+ if isinstance(avatarId, str):
+ avatarId = avatarId.decode(self._encoding)
+
+ def gotSoledad(soledad):
+ for iface in interfaces:
+ if iface is IAccount:
+ avatar = IMAPAccount(soledad, avatarId)
+ return (IAccount, avatar,
+ getattr(avatar, 'logout', lambda: None))
+ raise NotImplementedError(self, interfaces)
+
+ return self.lookupSoledadInstance(avatarId).addCallback(gotSoledad)
+
+ def lookupSoledadInstance(self, userid):
+ soledad = self._soledad_sessions[userid]
+ # XXX this should return the instance after whenReady callback
+ return defer.succeed(soledad)
+
+
+class IMAPTokenChecker(LocalSoledadTokenChecker):
+ """A credentials checker that will lookup a token for the IMAP service.
+ For now it will be using the same identifier than SMTPTokenChecker"""
+
+ service = 'mail_auth'
+
+
+class LocalSoledadIMAPServer(LEAPIMAPServer):
+
+ """
+ An IMAP Server that authenticates against a LocalSoledad store.
+ """
+
+ def __init__(self, soledad_sessions, *args, **kw):
+
+ LEAPIMAPServer.__init__(self, *args, **kw)
+
+ realm = LocalSoledadIMAPRealm(soledad_sessions)
+ portal = Portal(realm)
+ checker = IMAPTokenChecker(soledad_sessions)
+ self.checker = checker
+ self.portal = portal
+ portal.registerChecker(checker)
+
+
+class LeapIMAPFactory(ServerFactory):
+
+ """
+ Factory for a IMAP4 server with soledad remote sync and gpg-decryption
+ capabilities.
+ """
+
+ protocol = LocalSoledadIMAPServer
+
+ def __init__(self, soledad_sessions):
+ """
+ Initializes the server factory.
+
+ :param soledad_sessions: a dict-like object, containing instances
+ of a Store (soledad instances), indexed by
+ userid.
+ """
+ self._soledad_sessions = soledad_sessions
+ self._connections = defaultdict()
+
+ def buildProtocol(self, addr):
+ """
+ Return a protocol suitable for the job.
+
+ :param addr: remote ip address
+ :type addr: str
+ """
+ # TODO should reject anything from addr != localhost,
+ # just in case.
+ log.msg("Building protocol for connection %s" % addr)
+ imapProtocol = self.protocol(self._soledad_sessions)
+ self._connections[addr] = imapProtocol
+ return imapProtocol
+
+ def stopFactory(self):
+ # say bye!
+ for conn, proto in self._connections.items():
+ log.msg("Closing connections for %s" % conn)
+ proto.close_server_connection()
+
+ def doStop(self):
+ """
+ Stops imap service (fetcher, factory and port).
+ """
+ return ServerFactory.doStop(self)
+
+
+def run_service(soledad_sessions, port=IMAP_PORT):
+ """
+ Main entry point to run the service from the client.
+
+ :param soledad_sessions: a dict-like object, containing instances
+ of a Store (soledad instances), indexed by userid.
+
+ :returns: the port as returned by the reactor when starts listening, and
+ the factory for the protocol.
+ :rtype: tuple
+ """
+ factory = LeapIMAPFactory(soledad_sessions)
+
+ try:
+ interface = "localhost"
+ # don't bind just to localhost if we are running on docker since we
+ # won't be able to access imap from the host
+ if os.environ.get("LEAP_DOCKERIZED"):
+ interface = ''
+
+ # TODO use Endpoints !!!
+ tport = reactor.listenTCP(port, factory,
+ interface=interface)
+ except CannotListenError:
+ logger.error("IMAP Service failed to start: "
+ "cannot listen in port %s" % (port,))
+ except Exception as exc:
+ logger.error("Error launching IMAP service: %r" % (exc,))
+ else:
+ # all good.
+
+ if DO_MANHOLE:
+ # TODO get pass from env var.too.
+ manhole_factory = manhole.getManholeFactory(
+ {'f': factory,
+ 'gm': factory.theAccount.getMailbox},
+ "boss", "leap")
+ # TODO use Endpoints !!!
+ reactor.listenTCP(manhole.MANHOLE_PORT, manhole_factory,
+ interface="127.0.0.1")
+ logger.debug("IMAP4 Server is RUNNING in port %s" % (port,))
+ emit_async(catalog.IMAP_SERVICE_STARTED, str(port))
+
+ # FIXME -- change service signature
+ return tport, factory
+
+ # not ok, signal error.
+ emit_async(catalog.IMAP_SERVICE_FAILED_TO_START, str(port))
diff --git a/src/leap/bitmask/mail/imap/service/manhole.py b/src/leap/bitmask/mail/imap/service/manhole.py
new file mode 100644
index 0000000..c83ae89
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/service/manhole.py
@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+# manhole.py
+# Copyright (C) 2014 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Utilities for enabling the manhole administrative interface into the
+LEAP Mail application.
+"""
+MANHOLE_PORT = 2222
+
+
+def getManholeFactory(namespace, user, secret):
+ """
+ Get an administrative manhole into the application.
+
+ :param namespace: the namespace to show in the manhole
+ :type namespace: dict
+ :param user: the user to authenticate into the administrative shell.
+ :type user: str
+ :param secret: pass for this manhole
+ :type secret: str
+ """
+ import string
+
+ from twisted.cred.portal import Portal
+ from twisted.conch import manhole, manhole_ssh
+ from twisted.conch.insults import insults
+ from twisted.cred.checkers import (
+ InMemoryUsernamePasswordDatabaseDontUse as MemoryDB)
+
+ from rlcompleter import Completer
+
+ class EnhancedColoredManhole(manhole.ColoredManhole):
+ """
+ A Manhole with some primitive autocomplete support.
+ """
+ # TODO use introspection to make life easier
+
+ def find_common(self, l):
+ """
+ find common parts in thelist items
+ ex: 'ab' for ['abcd','abce','abf']
+ requires an ordered list
+ """
+ if len(l) == 1:
+ return l[0]
+
+ init = l[0]
+ for item in l[1:]:
+ for i, (x, y) in enumerate(zip(init, item)):
+ if x != y:
+ init = "".join(init[:i])
+ break
+
+ if not init:
+ return None
+ return init
+
+ def handle_TAB(self):
+ """
+ Trap the TAB keystroke.
+ """
+ necessarypart = "".join(self.lineBuffer).split(' ')[-1]
+ completer = Completer(globals())
+ if completer.complete(necessarypart, 0):
+ matches = list(set(completer.matches)) # has multiples
+
+ if len(matches) == 1:
+ length = len(necessarypart)
+ self.lineBuffer = self.lineBuffer[:-length]
+ self.lineBuffer.extend(matches[0])
+ self.lineBufferIndex = len(self.lineBuffer)
+ else:
+ matches.sort()
+ commons = self.find_common(matches)
+ if commons:
+ length = len(necessarypart)
+ self.lineBuffer = self.lineBuffer[:-length]
+ self.lineBuffer.extend(commons)
+ self.lineBufferIndex = len(self.lineBuffer)
+
+ self.terminal.nextLine()
+ while matches:
+ matches, part = matches[4:], matches[:4]
+ for item in part:
+ self.terminal.write('%s' % item.ljust(30))
+ self.terminal.write('\n')
+ self.terminal.nextLine()
+
+ self.terminal.eraseLine()
+ self.terminal.cursorBackward(self.lineBufferIndex + 5)
+ self.terminal.write("%s %s" % (
+ self.ps[self.pn], "".join(self.lineBuffer)))
+
+ def keystrokeReceived(self, keyID, modifier):
+ """
+ Act upon any keystroke received.
+ """
+ self.keyHandlers.update({'\b': self.handle_BACKSPACE})
+ m = self.keyHandlers.get(keyID)
+ if m is not None:
+ m()
+ elif keyID in string.printable:
+ self.characterReceived(keyID, False)
+
+ sshRealm = manhole_ssh.TerminalRealm()
+
+ def chainedProtocolFactory():
+ return insults.ServerProtocol(EnhancedColoredManhole, namespace)
+
+ sshRealm = manhole_ssh.TerminalRealm()
+ sshRealm.chainedProtocolFactory = chainedProtocolFactory
+
+ portal = Portal(
+ sshRealm, [MemoryDB(**{user: secret})])
+
+ f = manhole_ssh.ConchFactory(portal)
+ return f
diff --git a/src/leap/bitmask/mail/imap/service/notes.txt b/src/leap/bitmask/mail/imap/service/notes.txt
new file mode 100644
index 0000000..623e122
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/service/notes.txt
@@ -0,0 +1,81 @@
+T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP]
+* OK [CAPABILITY IMAP4rev1 IDLE NAMESPACE] Twisted IMAP4rev1 Ready.
+
+##
+T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP]
+NCLJ1 CAPABILITY.
+
+##
+T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP]
+* CAPABILITY IMAP4rev1 IDLE NAMESPACE.
+NCLJ1 OK CAPABILITY completed.
+
+##
+T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP]
+NCLJ2 LOGIN user "pass".
+
+#
+T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP]
+NCLJ2 OK LOGIN succeeded.
+
+##
+T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP]
+NCLJ3 CAPABILITY.
+
+#
+T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP]
+* CAPABILITY IMAP4rev1 IDLE NAMESPACE.
+NCLJ3 OK CAPABILITY completed.
+
+#
+T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP]
+NCLJ4 LIST "" "".
+
+##
+T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP]
+* LIST (\Seen \Answered \Flagged \Deleted \Draft \Recent List) "/" "INBOX".
+NCLJ4 OK LIST completed.
+
+#
+T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP]
+NCLJ5 LIST "" "*".
+
+##
+T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP]
+* LIST (\Seen \Answered \Flagged \Deleted \Draft \Recent List) "/" "INBOX".
+NCLJ5 OK LIST completed.
+
+#
+T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP]
+NCLJ6 SELECT INBOX.
+
+#
+T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP]
+* 0 EXISTS.
+* 3 RECENT.
+* FLAGS (\Seen \Answered \Flagged \Deleted \Draft \Recent List).
+* OK [UIDVALIDITY 42].
+NCLJ6 OK [READ-WRITE] SELECT successful.
+
+#
+T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP]
+NCLJ7 EXAMINE INBOX.
+
+##
+T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP]
+* 0 EXISTS.
+* 3 RECENT.
+* FLAGS (\Seen \Answered \Flagged \Deleted \Draft \Recent List).
+* OK [UIDVALIDITY 42].
+NCLJ7 OK [READ-ONLY] EXAMINE successful.
+
+#
+T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP]
+NCLJ8 LOGOUT.
+
+##
+T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP]
+* BYE Nice talking to you.
+NCLJ8 OK LOGOUT successful.
+
+
diff --git a/src/leap/bitmask/mail/imap/service/rfc822.message b/src/leap/bitmask/mail/imap/service/rfc822.message
new file mode 100644
index 0000000..ee97ab9
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/service/rfc822.message
@@ -0,0 +1,86 @@
+Return-Path: <twisted-commits-admin@twistedmatrix.com>
+Delivered-To: exarkun@meson.dyndns.org
+Received: from localhost [127.0.0.1]
+ by localhost with POP3 (fetchmail-6.2.1)
+ for exarkun@localhost (single-drop); Thu, 20 Mar 2003 14:50:20 -0500 (EST)
+Received: from pyramid.twistedmatrix.com (adsl-64-123-27-105.dsl.austtx.swbell.net [64.123.27.105])
+ by intarweb.us (Postfix) with ESMTP id 4A4A513EA4
+ for <exarkun@meson.dyndns.org>; Thu, 20 Mar 2003 14:49:27 -0500 (EST)
+Received: from localhost ([127.0.0.1] helo=pyramid.twistedmatrix.com)
+ by pyramid.twistedmatrix.com with esmtp (Exim 3.35 #1 (Debian))
+ id 18w648-0007Vl-00; Thu, 20 Mar 2003 13:51:04 -0600
+Received: from acapnotic by pyramid.twistedmatrix.com with local (Exim 3.35 #1 (Debian))
+ id 18w63j-0007VK-00
+ for <twisted-commits@twistedmatrix.com>; Thu, 20 Mar 2003 13:50:39 -0600
+To: twisted-commits@twistedmatrix.com
+From: etrepum CVS <etrepum@twistedmatrix.com>
+Reply-To: twisted-python@twistedmatrix.com
+X-Mailer: CVSToys
+Message-Id: <E18w63j-0007VK-00@pyramid.twistedmatrix.com>
+Subject: [Twisted-commits] rebuild now works on python versions from 2.2.0 and up.
+Sender: twisted-commits-admin@twistedmatrix.com
+Errors-To: twisted-commits-admin@twistedmatrix.com
+X-BeenThere: twisted-commits@twistedmatrix.com
+X-Mailman-Version: 2.0.11
+Precedence: bulk
+List-Help: <mailto:twisted-commits-request@twistedmatrix.com?subject=help>
+List-Post: <mailto:twisted-commits@twistedmatrix.com>
+List-Subscribe: <http://twistedmatrix.com/cgi-bin/mailman/listinfo/twisted-commits>,
+ <mailto:twisted-commits-request@twistedmatrix.com?subject=subscribe>
+List-Id: <twisted-commits.twistedmatrix.com>
+List-Unsubscribe: <http://twistedmatrix.com/cgi-bin/mailman/listinfo/twisted-commits>,
+ <mailto:twisted-commits-request@twistedmatrix.com?subject=unsubscribe>
+List-Archive: <http://twistedmatrix.com/pipermail/twisted-commits/>
+Date: Thu, 20 Mar 2003 13:50:39 -0600
+
+Modified files:
+Twisted/twisted/python/rebuild.py 1.19 1.20
+
+Log message:
+rebuild now works on python versions from 2.2.0 and up.
+
+
+ViewCVS links:
+http://twistedmatrix.com/users/jh.twistd/viewcvs/cgi/viewcvs.cgi/twisted/python/rebuild.py.diff?r1=text&tr1=1.19&r2=text&tr2=1.20&cvsroot=Twisted
+
+Index: Twisted/twisted/python/rebuild.py
+diff -u Twisted/twisted/python/rebuild.py:1.19 Twisted/twisted/python/rebuild.py:1.20
+--- Twisted/twisted/python/rebuild.py:1.19 Fri Jan 17 13:50:49 2003
++++ Twisted/twisted/python/rebuild.py Thu Mar 20 11:50:08 2003
+@@ -206,15 +206,27 @@
+ clazz.__dict__.clear()
+ clazz.__getattr__ = __getattr__
+ clazz.__module__ = module.__name__
++ if newclasses:
++ import gc
++ if (2, 2, 0) <= sys.version_info[:3] < (2, 2, 2):
++ hasBrokenRebuild = 1
++ gc_objects = gc.get_objects()
++ else:
++ hasBrokenRebuild = 0
+ for nclass in newclasses:
+ ga = getattr(module, nclass.__name__)
+ if ga is nclass:
+ log.msg("WARNING: new-class %s not replaced by reload!" % reflect.qual(nclass))
+ else:
+- import gc
+- for r in gc.get_referrers(nclass):
+- if isinstance(r, nclass):
++ if hasBrokenRebuild:
++ for r in gc_objects:
++ if not getattr(r, '__class__', None) is nclass:
++ continue
+ r.__class__ = ga
++ else:
++ for r in gc.get_referrers(nclass):
++ if getattr(r, '__class__', None) is nclass:
++ r.__class__ = ga
+ if doLog:
+ log.msg('')
+ log.msg(' (fixing %s): ' % str(module.__name__))
+
+
+_______________________________________________
+Twisted-commits mailing list
+Twisted-commits@twistedmatrix.com
+http://twistedmatrix.com/cgi-bin/mailman/listinfo/twisted-commits
diff --git a/src/leap/bitmask/mail/imap/tests/.gitignore b/src/leap/bitmask/mail/imap/tests/.gitignore
new file mode 100644
index 0000000..60baa9c
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/.gitignore
@@ -0,0 +1 @@
+data/*
diff --git a/src/leap/bitmask/mail/imap/tests/getmail b/src/leap/bitmask/mail/imap/tests/getmail
new file mode 100755
index 0000000..dd3fa0b
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/getmail
@@ -0,0 +1,344 @@
+#!/usr/bin/env python
+
+# Copyright (c) Twisted Matrix Laboratories.
+# See LICENSE in twisted for details.
+
+# Modifications by LEAP Developers 2014 to fit
+# Bitmask configuration settings.
+"""
+Simple IMAP4 client which displays the subjects of all messages in a
+particular mailbox.
+"""
+
+import os
+import sys
+
+from twisted.internet import protocol
+from twisted.internet import ssl
+from twisted.internet import defer
+from twisted.internet import stdio
+from twisted.mail import imap4
+from twisted.protocols import basic
+from twisted.python import log
+
+# Global options stored here from main
+_opts = {}
+
+
+class TrivialPrompter(basic.LineReceiver):
+ from os import linesep as delimiter
+
+ promptDeferred = None
+
+ def prompt(self, msg):
+ assert self.promptDeferred is None
+ self.display(msg)
+ self.promptDeferred = defer.Deferred()
+ return self.promptDeferred
+
+ def display(self, msg):
+ self.transport.write(msg)
+
+ def lineReceived(self, line):
+ if self.promptDeferred is None:
+ return
+ d, self.promptDeferred = self.promptDeferred, None
+ d.callback(line)
+
+
+class SimpleIMAP4Client(imap4.IMAP4Client):
+ """
+ A client with callbacks for greeting messages from an IMAP server.
+ """
+ greetDeferred = None
+
+ def serverGreeting(self, caps):
+ self.serverCapabilities = caps
+ if self.greetDeferred is not None:
+ d, self.greetDeferred = self.greetDeferred, None
+ d.callback(self)
+
+
+class SimpleIMAP4ClientFactory(protocol.ClientFactory):
+ usedUp = False
+
+ protocol = SimpleIMAP4Client
+
+ def __init__(self, username, onConn):
+ self.ctx = ssl.ClientContextFactory()
+
+ self.username = username
+ self.onConn = onConn
+
+ def buildProtocol(self, addr):
+ """
+ Initiate the protocol instance. Since we are building a simple IMAP
+ client, we don't bother checking what capabilities the server has. We
+ just add all the authenticators twisted.mail has.
+ """
+ assert not self.usedUp
+ self.usedUp = True
+
+ p = self.protocol(self.ctx)
+ p.factory = self
+ p.greetDeferred = self.onConn
+
+ p.registerAuthenticator(imap4.PLAINAuthenticator(self.username))
+ p.registerAuthenticator(imap4.LOGINAuthenticator(self.username))
+ p.registerAuthenticator(
+ imap4.CramMD5ClientAuthenticator(self.username))
+
+ return p
+
+ def clientConnectionFailed(self, connector, reason):
+ d, self.onConn = self.onConn, None
+ d.errback(reason)
+
+
+def cbServerGreeting(proto, username, password):
+ """
+ Initial callback - invoked after the server sends us its greet message.
+ """
+ # Hook up stdio
+ tp = TrivialPrompter()
+ stdio.StandardIO(tp)
+
+ # And make it easily accessible
+ proto.prompt = tp.prompt
+ proto.display = tp.display
+
+ # Try to authenticate securely
+ return proto.authenticate(
+ password).addCallback(
+ cbAuthentication,
+ proto).addErrback(
+ ebAuthentication, proto, username, password
+ )
+
+
+def ebConnection(reason):
+ """
+ Fallback error-handler. If anything goes wrong, log it and quit.
+ """
+ log.startLogging(sys.stdout)
+ log.err(reason)
+ return reason
+
+
+def cbAuthentication(result, proto):
+ """
+ Callback after authentication has succeeded.
+
+ Lists a bunch of mailboxes.
+ """
+ return proto.list("", "*"
+ ).addCallback(cbMailboxList, proto
+ )
+
+
+def ebAuthentication(failure, proto, username, password):
+ """
+ Errback invoked when authentication fails.
+
+ If it failed because no SASL mechanisms match, offer the user the choice
+ of logging in insecurely.
+
+ If you are trying to connect to your Gmail account, you will be here!
+ """
+ failure.trap(imap4.NoSupportedAuthentication)
+ return InsecureLogin(proto, username, password)
+
+
+def InsecureLogin(proto, username, password):
+ """
+ insecure-login.
+ """
+ return proto.login(username, password
+ ).addCallback(cbAuthentication, proto
+ )
+
+
+def cbMailboxList(result, proto):
+ """
+ Callback invoked when a list of mailboxes has been retrieved.
+ If we have a selected mailbox in the global options, we directly pick it.
+ Otherwise, we offer a prompt to let user choose one.
+ """
+ all_mbox_list = [e[2] for e in result]
+ s = '\n'.join(['%d. %s' % (n + 1, m) for (n, m) in zip(range(len(all_mbox_list)), all_mbox_list)])
+ if not s:
+ return defer.fail(Exception("No mailboxes exist on server!"))
+
+ selected_mailbox = _opts.get('mailbox')
+
+ if not selected_mailbox:
+ return proto.prompt(s + "\nWhich mailbox? [1] "
+ ).addCallback(cbPickMailbox, proto, all_mbox_list
+ )
+ else:
+ mboxes_lower = map(lambda s: s.lower(), all_mbox_list)
+ index = mboxes_lower.index(selected_mailbox.lower()) + 1
+ return cbPickMailbox(index, proto, all_mbox_list)
+
+
+def cbPickMailbox(result, proto, mboxes):
+ """
+ When the user selects a mailbox, "examine" it.
+ """
+ mbox = mboxes[int(result or '1') - 1]
+ return proto.examine(mbox
+ ).addCallback(cbExamineMbox, proto
+ )
+
+
+def cbExamineMbox(result, proto):
+ """
+ Callback invoked when examine command completes.
+
+ Retrieve the subject header of every message in the mailbox.
+ """
+ return proto.fetchSpecific('1:*',
+ headerType='HEADER.FIELDS',
+ headerArgs=['SUBJECT'],
+ ).addCallback(cbFetch, proto,
+ )
+
+
+def cbFetch(result, proto):
+ """
+ Display a listing of the messages in the mailbox, based on the collected
+ headers.
+ """
+ selected_subject = _opts.get('subject', None)
+ index = None
+
+ if result:
+ keys = result.keys()
+ keys.sort()
+
+ if selected_subject:
+ for k in keys:
+ # remove 'Subject: ' preffix plus eol
+ subject = result[k][0][2][9:].rstrip('\r\n')
+ if subject.lower() == selected_subject.lower():
+ index = k
+ break
+ else:
+ for k in keys:
+ proto.display('%s %s' % (k, result[k][0][2]))
+ else:
+ print "Hey, an empty mailbox!"
+
+ if not index:
+ return proto.prompt("\nWhich message? [1] (Q quits) "
+ ).addCallback(cbPickMessage, proto)
+ else:
+ return cbPickMessage(index, proto)
+
+
+def cbPickMessage(result, proto):
+ """
+ Pick a message.
+ """
+ if result == "Q":
+ print "Bye!"
+ return proto.logout()
+
+ return proto.fetchSpecific(
+ '%s' % result,
+ headerType='',
+ headerArgs=['BODY.PEEK[]'],
+ ).addCallback(cbShowmessage, proto)
+
+
+def cbShowmessage(result, proto):
+ """
+ Display message.
+ """
+ if result:
+ keys = result.keys()
+ keys.sort()
+ for k in keys:
+ proto.display('%s %s' % (k, result[k][0][2]))
+ else:
+ print "Hey, an empty message!"
+
+ return proto.logout()
+
+
+def cbClose(result):
+ """
+ Close the connection when we finish everything.
+ """
+ from twisted.internet import reactor
+ reactor.stop()
+
+
+def main():
+ import argparse
+ import ConfigParser
+ import sys
+ from twisted.internet import reactor
+
+ description = (
+ 'Get messages from a LEAP IMAP Proxy.\nThis is a '
+ 'debugging tool, do not use this to retrieve any sensitive '
+ 'information, or we will send ninjas to your house!')
+ epilog = (
+ 'In case you want to automate the usage of this utility '
+ 'you can place your credentials in a file pointed by '
+ 'BITMASK_CREDENTIALS. You need to have a [Credentials] '
+ 'section, with username=<user@provider> and password fields')
+
+ parser = argparse.ArgumentParser(description=description, epilog=epilog)
+ credentials = os.environ.get('BITMASK_CREDENTIALS')
+
+ if credentials:
+ try:
+ config = ConfigParser.ConfigParser()
+ config.read(credentials)
+ username = config.get('Credentials', 'username')
+ password = config.get('Credentials', 'password')
+ except Exception, e:
+ print "Error reading credentials file: {0}".format(e)
+ sys.exit()
+ else:
+ parser.add_argument('username', type=str)
+ parser.add_argument('password', type=str)
+
+ parser.add_argument('--mailbox', dest='mailbox', default=None,
+ help='Which mailbox to retrieve. Empty for interactive prompt.')
+ parser.add_argument('--subject', dest='subject', default=None,
+ help='A subject for retrieve a mail that matches. Empty for interactive prompt.')
+
+ ns = parser.parse_args()
+
+ if not credentials:
+ username = ns.username
+ password = ns.password
+
+ _opts['mailbox'] = ns.mailbox
+ _opts['subject'] = ns.subject
+
+ hostname = "localhost"
+ port = "1984"
+
+ onConn = defer.Deferred(
+ ).addCallback(cbServerGreeting, username, password
+ ).addErrback(ebConnection
+ ).addBoth(cbClose)
+
+ factory = SimpleIMAP4ClientFactory(username, onConn)
+
+ if port == '993':
+ reactor.connectSSL(
+ hostname, int(port), factory, ssl.ClientContextFactory())
+ else:
+ if not port:
+ port = 143
+ reactor.connectTCP(hostname, int(port), factory)
+ reactor.run()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/leap/bitmask/mail/imap/tests/imapclient.py b/src/leap/bitmask/mail/imap/tests/imapclient.py
new file mode 100755
index 0000000..c353cee
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/imapclient.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python
+
+# Copyright (c) Twisted Matrix Laboratories.
+# See LICENSE for details.
+
+
+"""
+Simple IMAP4 client which connects to our custome
+IMAP4 server: imapserver.py.
+"""
+
+import sys
+
+from twisted.internet import protocol
+from twisted.internet import defer
+from twisted.internet import stdio
+from twisted.mail import imap4
+from twisted.protocols import basic
+from twisted.python import util
+from twisted.python import log
+
+
+class TrivialPrompter(basic.LineReceiver):
+ # from os import linesep as delimiter
+
+ promptDeferred = None
+
+ def prompt(self, msg):
+ assert self.promptDeferred is None
+ self.display(msg)
+ self.promptDeferred = defer.Deferred()
+ return self.promptDeferred
+
+ def display(self, msg):
+ self.transport.write(msg)
+
+ def lineReceived(self, line):
+ if self.promptDeferred is None:
+ return
+ d, self.promptDeferred = self.promptDeferred, None
+ d.callback(line)
+
+
+class SimpleIMAP4Client(imap4.IMAP4Client):
+
+ """
+ Add callbacks when the client receives greeting messages from
+ an IMAP server.
+ """
+ greetDeferred = None
+
+ def serverGreeting(self, caps):
+ self.serverCapabilities = caps
+ if self.greetDeferred is not None:
+ d, self.greetDeferred = self.greetDeferred, None
+ d.callback(self)
+
+
+class SimpleIMAP4ClientFactory(protocol.ClientFactory):
+ usedUp = False
+ protocol = SimpleIMAP4Client
+
+ def __init__(self, username, onConn):
+ self.username = username
+ self.onConn = onConn
+
+ def buildProtocol(self, addr):
+ assert not self.usedUp
+ self.usedUp = True
+
+ p = self.protocol()
+ p.factory = self
+ p.greetDeferred = self.onConn
+
+ p.registerAuthenticator(imap4.PLAINAuthenticator(self.username))
+ p.registerAuthenticator(imap4.LOGINAuthenticator(self.username))
+ p.registerAuthenticator(
+ imap4.CramMD5ClientAuthenticator(self.username))
+
+ return p
+
+ def clientConnectionFailed(self, connector, reason):
+ d, self.onConn = self.onConn, None
+ d.errback(reason)
+
+
+def cbServerGreeting(proto, username, password):
+ """
+ Initial callback - invoked after the server sends us its greet message.
+ """
+ # Hook up stdio
+ tp = TrivialPrompter()
+ stdio.StandardIO(tp)
+
+ # And make it easily accessible
+ proto.prompt = tp.prompt
+ proto.display = tp.display
+
+ # Try to authenticate securely
+ return proto.authenticate(
+ password).addCallback(
+ cbAuthentication, proto).addErrback(
+ ebAuthentication, proto, username, password)
+
+
+def ebConnection(reason):
+ """
+ Fallback error-handler. If anything goes wrong, log it and quit.
+ """
+ log.startLogging(sys.stdout)
+ log.err(reason)
+ return reason
+
+
+def cbAuthentication(result, proto):
+ """
+ Callback after authentication has succeeded.
+ List a bunch of mailboxes.
+ """
+ return proto.list("", "*"
+ ).addCallback(cbMailboxList, proto
+ )
+
+
+def ebAuthentication(failure, proto, username, password):
+ """
+ Errback invoked when authentication fails.
+ If it failed because no SASL mechanisms match, offer the user the choice
+ of logging in insecurely.
+ If you are trying to connect to your Gmail account, you will be here!
+ """
+ failure.trap(imap4.NoSupportedAuthentication)
+ return proto.prompt(
+ "No secure authentication available. Login insecurely? (y/N) "
+ ).addCallback(cbInsecureLogin, proto, username, password
+ )
+
+
+def cbInsecureLogin(result, proto, username, password):
+ """
+ Callback for "insecure-login" prompt.
+ """
+ if result.lower() == "y":
+ # If they said yes, do it.
+ return proto.login(username, password
+ ).addCallback(cbAuthentication, proto
+ )
+ return defer.fail(Exception("Login failed for security reasons."))
+
+
+def cbMailboxList(result, proto):
+ """
+ Callback invoked when a list of mailboxes has been retrieved.
+ """
+ result = [e[2] for e in result]
+ s = '\n'.join(
+ ['%d. %s' % (n + 1, m) for (n, m) in zip(range(len(result)), result)])
+ if not s:
+ return defer.fail(Exception("No mailboxes exist on server!"))
+ return proto.prompt(s + "\nWhich mailbox? [1] "
+ ).addCallback(cbPickMailbox, proto, result
+ )
+
+
+def cbPickMailbox(result, proto, mboxes):
+ """
+ When the user selects a mailbox, "examine" it.
+ """
+ mbox = mboxes[int(result or '1') - 1]
+ return proto.status(mbox, 'MESSAGES', 'UNSEEN'
+ ).addCallback(cbMboxStatus, proto)
+
+
+def cbMboxStatus(result, proto):
+ print "You have %s messages (%s unseen)!" % (
+ result['MESSAGES'], result['UNSEEN'])
+ return proto.logout()
+
+
+def cbClose(result):
+ """
+ Close the connection when we finish everything.
+ """
+ from twisted.internet import reactor
+ reactor.stop()
+
+
+def main():
+ hostname = raw_input('IMAP4 Server Hostname: ')
+ port = raw_input('IMAP4 Server Port (the default is 143): ')
+ username = raw_input('IMAP4 Username: ')
+ password = util.getPassword('IMAP4 Password: ')
+
+ onConn = defer.Deferred(
+ ).addCallback(cbServerGreeting, username, password
+ ).addErrback(ebConnection
+ ).addBoth(cbClose)
+
+ factory = SimpleIMAP4ClientFactory(username, onConn)
+
+ from twisted.internet import reactor
+ conn = reactor.connectTCP(hostname, int(port), factory)
+ reactor.run()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/leap/bitmask/mail/imap/tests/regressions_mime_struct b/src/leap/bitmask/mail/imap/tests/regressions_mime_struct
new file mode 100755
index 0000000..0332664
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/regressions_mime_struct
@@ -0,0 +1,461 @@
+#!/usr/bin/env python
+
+# -*- coding: utf-8 -*-
+# regression_mime_struct
+# Copyright (C) 2014 LEAP
+# Copyright (c) Twisted Matrix Laboratories.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Simple Regression Tests for checking MIME struct handling using IMAP4 client.
+
+Iterates trough all mails under a given folder and tries to APPEND them to
+the server being tested. After FETCHING the pushed message, it compares
+the received version with the one that was saved, and exits with an error
+code if they do not match.
+"""
+import os
+import StringIO
+import sys
+
+from email.parser import Parser
+
+from twisted.internet import protocol
+from twisted.internet import ssl
+from twisted.internet import defer
+from twisted.internet import stdio
+from twisted.mail import imap4
+from twisted.protocols import basic
+from twisted.python import log
+
+
+REGRESSIONS_FOLDER = os.environ.get(
+ "REGRESSIONS_FOLDER", "regressions_test")
+print "[+] Using regressions folder:", REGRESSIONS_FOLDER
+
+parser = Parser()
+
+
+def get_msg_parts(raw):
+ """
+ Return a representation of the parts of a message suitable for
+ comparison.
+
+ :param raw: string for the message
+ :type raw: str
+ """
+ m = parser.parsestr(raw)
+ return [dict(part.items())
+ if part.is_multipart()
+ else part.get_payload()
+ for part in m.walk()]
+
+
+def compare_msg_parts(a, b):
+ """
+ Compare two sequences of parts of messages.
+
+ :param a: part sequence for message a
+ :param b: part sequence for message b
+
+ :return: True if both message sequences are equivalent.
+ :rtype: bool
+ """
+ # XXX This could be smarter and show the differences in the
+ # different parts when/where they differ.
+ #import pprint; pprint.pprint(a[0])
+ #import pprint; pprint.pprint(b[0])
+
+ def lowerkey(d):
+ return dict((k.lower(), v.replace('\r', ''))
+ for k, v in d.iteritems())
+
+ def eq(x, y):
+ # For dicts, we compare a variation with their keys
+ # in lowercase, and \r removed from their values
+ if all(map(lambda i: isinstance(i, dict), (x, y))):
+ x, y = map(lowerkey, (x, y))
+ return x == y
+
+ compare_vector = map(lambda tup: eq(tup[0], tup[1]), zip(a, b))
+ all_match = all(compare_vector)
+
+ if not all_match:
+ print "PARTS MISMATCH!"
+ print "vector: ", compare_vector
+ index = compare_vector.index(False)
+ from pprint import pprint
+ print "Expected:"
+ pprint(a[index])
+ print ("***")
+ print "Found:"
+ pprint(b[index])
+ print
+
+ return all_match
+
+
+def get_fd(string):
+ """
+ Return a file descriptor with the passed string
+ as content.
+ """
+ fd = StringIO.StringIO()
+ fd.write(string)
+ fd.seek(0)
+ return fd
+
+
+class TrivialPrompter(basic.LineReceiver):
+ promptDeferred = None
+
+ def prompt(self, msg):
+ assert self.promptDeferred is None
+ self.display(msg)
+ self.promptDeferred = defer.Deferred()
+ return self.promptDeferred
+
+ def display(self, msg):
+ self.transport.write(msg)
+
+ def lineReceived(self, line):
+ if self.promptDeferred is None:
+ return
+ d, self.promptDeferred = self.promptDeferred, None
+ d.callback(line)
+
+
+class SimpleIMAP4Client(imap4.IMAP4Client):
+ """
+ A client with callbacks for greeting messages from an IMAP server.
+ """
+ greetDeferred = None
+
+ def serverGreeting(self, caps):
+ self.serverCapabilities = caps
+ if self.greetDeferred is not None:
+ d, self.greetDeferred = self.greetDeferred, None
+ d.callback(self)
+
+
+class SimpleIMAP4ClientFactory(protocol.ClientFactory):
+ usedUp = False
+ protocol = SimpleIMAP4Client
+
+ def __init__(self, username, onConn):
+ self.ctx = ssl.ClientContextFactory()
+
+ self.username = username
+ self.onConn = onConn
+
+ def buildProtocol(self, addr):
+ """
+ Initiate the protocol instance. Since we are building a simple IMAP
+ client, we don't bother checking what capabilities the server has. We
+ just add all the authenticators twisted.mail has. Note: Gmail no
+ longer uses any of the methods below, it's been using XOAUTH since
+ 2010.
+ """
+ assert not self.usedUp
+ self.usedUp = True
+
+ p = self.protocol(self.ctx)
+ p.factory = self
+ p.greetDeferred = self.onConn
+
+ p.registerAuthenticator(imap4.PLAINAuthenticator(self.username))
+ p.registerAuthenticator(imap4.LOGINAuthenticator(self.username))
+ p.registerAuthenticator(
+ imap4.CramMD5ClientAuthenticator(self.username))
+
+ return p
+
+ def clientConnectionFailed(self, connector, reason):
+ d, self.onConn = self.onConn, None
+ d.errback(reason)
+
+
+def cbServerGreeting(proto, username, password):
+ """
+ Initial callback - invoked after the server sends us its greet message.
+ """
+ # Hook up stdio
+ tp = TrivialPrompter()
+ stdio.StandardIO(tp)
+
+ # And make it easily accessible
+ proto.prompt = tp.prompt
+ proto.display = tp.display
+
+ # Try to authenticate securely
+ return proto.authenticate(
+ password).addCallback(
+ cbAuthentication,
+ proto).addErrback(
+ ebAuthentication, proto, username, password
+ )
+
+
+def ebConnection(reason):
+ """
+ Fallback error-handler. If anything goes wrong, log it and quit.
+ """
+ log.startLogging(sys.stdout)
+ log.err(reason)
+ return reason
+
+
+def cbAuthentication(result, proto):
+ """
+ Callback after authentication has succeeded.
+
+ Lists a bunch of mailboxes.
+ """
+ return proto.select(
+ REGRESSIONS_FOLDER
+ ).addCallback(
+ cbSelectMbox, proto
+ ).addErrback(
+ ebSelectMbox, proto, REGRESSIONS_FOLDER)
+
+
+def ebAuthentication(failure, proto, username, password):
+ """
+ Errback invoked when authentication fails.
+
+ If it failed because no SASL mechanisms match, offer the user the choice
+ of logging in insecurely.
+
+ If you are trying to connect to your Gmail account, you will be here!
+ """
+ failure.trap(imap4.NoSupportedAuthentication)
+ return InsecureLogin(proto, username, password)
+
+
+def InsecureLogin(proto, username, password):
+ """
+ Raise insecure-login error.
+ """
+ return proto.login(
+ username, password
+ ).addCallback(
+ cbAuthentication, proto)
+
+
+def cbSelectMbox(result, proto):
+ """
+ Callback invoked when select command finishes successfully.
+
+ If any message is in the test folder, it will flag them as deleted and
+ expunge.
+ If no messages found, it will start with the APPEND tests.
+ """
+ print "SELECT: %s EXISTS " % result.get("EXISTS", "??")
+
+ if result["EXISTS"] != 0:
+ # Flag as deleted, expunge, and do an examine again.
+ print "There is mail here, will delete..."
+ return cbDeleteAndExpungeTestFolder(proto)
+
+ else:
+ return cbAppendNextMessage(proto)
+
+
+def ebSelectMbox(failure, proto, folder):
+ """
+ Errback invoked when the examine command fails.
+
+ Creates the folder.
+ """
+ log.err(failure)
+ log.msg("Folder %r does not exist. Creating..." % (folder,))
+ return proto.create(folder).addCallback(cbAuthentication, proto)
+
+
+def ebExpunge(failure):
+ log.err(failure)
+
+
+def cbDeleteAndExpungeTestFolder(proto):
+ """
+ Callback invoked fom cbExamineMbox when the number of messages in the
+ mailbox is not zero. It flags all messages as deleted and expunge the
+ mailbox.
+ """
+ return proto.setFlags(
+ "1:*", ("\\Deleted",)
+ ).addCallback(
+ lambda r: proto.expunge()
+ ).addCallback(
+ cbExpunge, proto
+ ).addErrback(
+ ebExpunge)
+
+
+def cbExpunge(result, proto):
+ return proto.select(
+ REGRESSIONS_FOLDER
+ ).addCallback(
+ cbSelectMbox, proto
+ ).addErrback(ebSettingDeleted, proto)
+
+
+def ebSettingDeleted(failure, proto):
+ """
+ Report errors during deletion of messages in the mailbox.
+ """
+ print failure.getTraceback()
+
+
+def cbAppendNextMessage(proto):
+ """
+ Appends the next message in the global queue to the test folder.
+ """
+ # 1. Get the next test message from global tuple.
+ try:
+ next_sample = SAMPLES.pop()
+ except IndexError:
+ # we're done!
+ return proto.logout()
+
+ print "\nAPPEND %s" % (next_sample,)
+ raw = open(next_sample).read()
+ msg = get_fd(raw)
+ return proto.append(
+ REGRESSIONS_FOLDER, msg
+ ).addCallback(
+ lambda r: proto.select(REGRESSIONS_FOLDER)
+ ).addCallback(
+ cbAppend, proto, raw
+ ).addErrback(
+ ebAppend, proto, raw)
+
+
+def cbAppend(result, proto, orig_msg):
+ """
+ Fetches the message right after an append.
+ """
+ # XXX keep account of highest UID
+ uid = "1:*"
+
+ return proto.fetchSpecific(
+ '%s' % uid,
+ headerType='',
+ headerArgs=['BODY.PEEK[]'],
+ ).addCallback(
+ cbCompareMessage, proto, orig_msg
+ ).addErrback(ebAppend, proto, orig_msg)
+
+
+def ebAppend(failure, proto, raw):
+ """
+ Errorback for the append operation
+ """
+ print "ERROR WHILE APPENDING!"
+ print failure.getTraceback()
+
+
+def cbPickMessage(result, proto):
+ """
+ Pick a message.
+ """
+ return proto.fetchSpecific(
+ '%s' % result,
+ headerType='',
+ headerArgs=['BODY.PEEK[]'],
+ ).addCallback(cbCompareMessage, proto)
+
+
+def cbCompareMessage(result, proto, raw):
+ """
+ Display message and compare it with the original one.
+ """
+ parts_orig = get_msg_parts(raw)
+
+ if result:
+ keys = result.keys()
+ keys.sort()
+ else:
+ print "[-] GOT NO RESULT"
+ return proto.logout()
+
+ latest = max(keys)
+
+ fetched_msg = result[latest][0][2]
+ parts_fetched = get_msg_parts(fetched_msg)
+
+ equal = compare_msg_parts(
+ parts_orig,
+ parts_fetched)
+
+ if equal:
+ print "[+] MESSAGES MATCH"
+ return cbAppendNextMessage(proto)
+ else:
+ print "[-] ERROR: MESSAGES DO NOT MATCH !!!"
+ print " ABORTING COMPARISON..."
+ # FIXME logout and print the subject ...
+ return proto.logout()
+
+
+def cbClose(result):
+ """
+ Close the connection when we finish everything.
+ """
+ from twisted.internet import reactor
+ reactor.stop()
+
+
+def main():
+ import glob
+ import sys
+
+ if len(sys.argv) != 4:
+ print "Usage: regressions <user> <pass> <samples-folder>"
+ sys.exit()
+
+ hostname = "localhost"
+ port = "1984"
+ username = sys.argv[1]
+ password = sys.argv[2]
+
+ samplesdir = sys.argv[3]
+
+ if not os.path.isdir(samplesdir):
+ print ("Could not find samples folder! "
+ "Make sure of copying mail_breaker contents there.")
+ sys.exit()
+
+ samples = glob.glob(samplesdir + '/*')
+
+ global SAMPLES
+ SAMPLES = []
+ SAMPLES += samples
+
+ onConn = defer.Deferred(
+ ).addCallback(
+ cbServerGreeting, username, password
+ ).addErrback(
+ ebConnection
+ ).addBoth(cbClose)
+
+ factory = SimpleIMAP4ClientFactory(username, onConn)
+
+ from twisted.internet import reactor
+ reactor.connectTCP(hostname, int(port), factory)
+ reactor.run()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/leap/bitmask/mail/imap/tests/rfc822.message b/src/leap/bitmask/mail/imap/tests/rfc822.message
new file mode 120000
index 0000000..b19cc28
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/rfc822.message
@@ -0,0 +1 @@
+../../tests/rfc822.message \ No newline at end of file
diff --git a/src/leap/bitmask/mail/imap/tests/rfc822.multi-minimal.message b/src/leap/bitmask/mail/imap/tests/rfc822.multi-minimal.message
new file mode 120000
index 0000000..e0aa678
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/rfc822.multi-minimal.message
@@ -0,0 +1 @@
+../../tests/rfc822.multi-minimal.message \ No newline at end of file
diff --git a/src/leap/bitmask/mail/imap/tests/rfc822.multi-nested.message b/src/leap/bitmask/mail/imap/tests/rfc822.multi-nested.message
new file mode 120000
index 0000000..306d0de
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/rfc822.multi-nested.message
@@ -0,0 +1 @@
+../../tests/rfc822.multi-nested.message \ No newline at end of file
diff --git a/src/leap/bitmask/mail/imap/tests/rfc822.multi-signed.message b/src/leap/bitmask/mail/imap/tests/rfc822.multi-signed.message
new file mode 120000
index 0000000..4172244
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/rfc822.multi-signed.message
@@ -0,0 +1 @@
+../../tests/rfc822.multi-signed.message \ No newline at end of file
diff --git a/src/leap/bitmask/mail/imap/tests/rfc822.multi.message b/src/leap/bitmask/mail/imap/tests/rfc822.multi.message
new file mode 120000
index 0000000..62057d2
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/rfc822.multi.message
@@ -0,0 +1 @@
+../../tests/rfc822.multi.message \ No newline at end of file
diff --git a/src/leap/bitmask/mail/imap/tests/rfc822.plain.message b/src/leap/bitmask/mail/imap/tests/rfc822.plain.message
new file mode 120000
index 0000000..5bab0e8
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/rfc822.plain.message
@@ -0,0 +1 @@
+../../tests/rfc822.plain.message \ No newline at end of file
diff --git a/src/leap/bitmask/mail/imap/tests/stress_tests_imap.zsh b/src/leap/bitmask/mail/imap/tests/stress_tests_imap.zsh
new file mode 100755
index 0000000..544faca
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/stress_tests_imap.zsh
@@ -0,0 +1,178 @@
+#!/bin/zsh
+# BATCH STRESS TEST FOR IMAP ----------------------
+# http://imgs.xkcd.com/comics/science.jpg
+#
+# Run imaptest against a LEAP IMAP server
+# for a fixed period of time, and collect output.
+#
+# Author: Kali Kaneko
+# Date: 2014 01 26
+#
+# To run, you need to have `imaptest` in your path.
+# See:
+# http://www.imapwiki.org/ImapTest/Installation
+#
+# For the tests, I'm using a 10MB file sample that
+# can be downloaded from:
+# http://www.dovecot.org/tmp/dovecot-crlf
+#
+# Want to contribute to benchmarking?
+#
+# 1. Create a pristine account in a bitmask provider.
+#
+# 2. Launch your bitmask client, with different flags
+# if you desire.
+#
+# For example to try the nosync flag in sqlite:
+#
+# LEAP_SQLITE_NOSYNC=1 bitmask --debug -N --offline -l /tmp/leap.log
+#
+# 3. Run at several points in time (ie: just after
+# launching the bitmask client. one minute after,
+# ten minutes after)
+#
+# mkdir data
+# cd data
+# ../leap_tests_imap.zsh | tee sqlite_nosync_run2.log
+#
+# 4. Submit your results to: kali at leap dot se
+# together with the logs of the bitmask run.
+#
+# Please provide also details about your system, and
+# the type of hard disk setup you are running against.
+#
+
+# ------------------------------------------------
+# Edit these variables if you are too lazy to pass
+# the user and mbox as parameters. Like me.
+
+USER="test_f14@dev.bitmask.net"
+MBOX="~/leap/imaptest/data/dovecot-crlf"
+
+HOST="localhost"
+PORT="1984"
+
+# in case you have it aliased
+GREP="/bin/grep"
+IMAPTEST="imaptest"
+
+# -----------------------------------------------
+#
+# These should be kept constant across benchmarking
+# runs across different machines, for comparability.
+
+DURATION=200
+NUM_MSG=200
+
+
+# TODO add another function, and a cli flag, to be able
+# to take several aggretates spaced in time, along a period
+# of several minutes.
+
+imaptest_cmd() {
+ stdbuf -o0 ${IMAPTEST} user=${USER} pass=1234 host=${HOST} \
+ port=${PORT} mbox=${MBOX} clients=1 msgs=${NUM_MSG} \
+ no_pipelining 2>/dev/null
+}
+
+stress_imap() {
+ mkfifo imap_pipe
+ cat imap_pipe | tee output &
+ imaptest_cmd >> imap_pipe
+}
+
+wait_and_kill() {
+ while :
+ do
+ sleep $DURATION
+ pkill -2 imaptest
+ rm imap_pipe
+ break
+ done
+}
+
+print_results() {
+ sleep 1
+ echo
+ echo
+ echo "AGGREGATED RESULTS"
+ echo "----------------------"
+ echo "\tavg\tstdev"
+ $GREP "avg" ./output | sed -e 's/^ *//g' -e 's/ *$//g' | \
+ gawk '
+function avg(data, count) {
+ sum=0;
+ for( x=0; x <= count-1; x++) {
+ sum += data[x];
+ }
+ return sum/count;
+}
+function std_dev(data, count) {
+ sum=0;
+ for( x=0; x <= count-1; x++) {
+ sum += data[x];
+ }
+ average = sum/count;
+
+ sumsq=0;
+ for( x=0; x <= count-1; x++) {
+ sumsq += (data[x] - average)^2;
+ }
+ return sqrt(sumsq/count);
+}
+BEGIN {
+ cnt = 0
+} END {
+
+printf("LOGI:\t%04.2lf\t%04.2f\n", avg(array[1], NR), std_dev(array[1], NR));
+printf("LIST:\t%04.2lf\t%04.2f\n", avg(array[2], NR), std_dev(array[2], NR));
+printf("STAT:\t%04.2lf\t%04.2f\n", avg(array[3], NR), std_dev(array[3], NR));
+printf("SELE:\t%04.2lf\t%04.2f\n", avg(array[4], NR), std_dev(array[4], NR));
+printf("FETC:\t%04.2lf\t%04.2f\n", avg(array[5], NR), std_dev(array[5], NR));
+printf("FET2:\t%04.2lf\t%04.2f\n", avg(array[6], NR), std_dev(array[6], NR));
+printf("STOR:\t%04.2lf\t%04.2f\n", avg(array[7], NR), std_dev(array[7], NR));
+printf("DELE:\t%04.2lf\t%04.2f\n", avg(array[8], NR), std_dev(array[8], NR));
+printf("EXPU:\t%04.2lf\t%04.2f\n", avg(array[9], NR), std_dev(array[9], NR));
+printf("APPE:\t%04.2lf\t%04.2f\n", avg(array[10], NR), std_dev(array[10], NR));
+printf("LOGO:\t%04.2lf\t%04.2f\n", avg(array[11], NR), std_dev(array[11], NR));
+
+print ""
+print "TOT samples", NR;
+}
+{
+ it = cnt++;
+ array[1][it] = $1;
+ array[2][it] = $2;
+ array[3][it] = $3;
+ array[4][it] = $4;
+ array[5][it] = $5;
+ array[6][it] = $6;
+ array[7][it] = $7;
+ array[8][it] = $8;
+ array[9][it] = $9;
+ array[10][it] = $10;
+ array[11][it] = $11;
+}'
+}
+
+
+{ test $1 = "--help" } && {
+ echo "Usage: $0 [user@provider] [/path/to/sample.mbox]"
+ exit 0
+}
+
+# If the first parameter is passed, take it as the user
+{ test $1 } && {
+ USER=$1
+}
+
+# If the second parameter is passed, take it as the mbox
+{ test $2 } && {
+ MBOX=$2
+}
+
+echo "[+] LEAP IMAP TESTS"
+echo "[+] Running imaptest for $DURATION seconds with $NUM_MSG messages"
+wait_and_kill &
+stress_imap
+print_results
diff --git a/src/leap/bitmask/mail/imap/tests/test_imap.py b/src/leap/bitmask/mail/imap/tests/test_imap.py
new file mode 100644
index 0000000..9cca17f
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/test_imap.py
@@ -0,0 +1,1060 @@
+# -*- coding: utf-8 -*-
+# test_imap.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Test case for leap.email.imap.server
+TestCases taken from twisted tests and modified to make them work
+against our implementation of the IMAPAccount.
+
+@authors: Kali Kaneko, <kali@leap.se>
+XXX add authors from the original twisted tests.
+
+@license: GPLv3, see included LICENSE file
+"""
+# XXX review license of the original tests!!!
+import os
+import string
+import types
+
+
+from twisted.mail import imap4
+from twisted.internet import defer
+from twisted.python import util
+from twisted.python import failure
+
+from twisted import cred
+
+from leap.mail.imap.mailbox import IMAPMailbox
+from leap.mail.imap.messages import CaseInsensitiveDict
+from leap.mail.testing.imap import IMAP4HelperMixin
+
+
+TEST_USER = "testuser@leap.se"
+TEST_PASSWD = "1234"
+
+
+def strip(f):
+ return lambda result, f=f: f()
+
+
+def sortNest(l):
+ l = l[:]
+ l.sort()
+ for i in range(len(l)):
+ if isinstance(l[i], types.ListType):
+ l[i] = sortNest(l[i])
+ elif isinstance(l[i], types.TupleType):
+ l[i] = tuple(sortNest(list(l[i])))
+ return l
+
+
+class TestRealm:
+ """
+ A minimal auth realm for testing purposes only
+ """
+ theAccount = None
+
+ def requestAvatar(self, avatarId, mind, *interfaces):
+ return imap4.IAccount, self.theAccount, lambda: None
+
+#
+# TestCases
+#
+
+# DEBUG ---
+# from twisted.internet.base import DelayedCall
+# DelayedCall.debug = True
+
+
+class LEAPIMAP4ServerTestCase(IMAP4HelperMixin):
+
+ """
+ Tests for the generic behavior of the LEAPIMAP4Server
+ which, right now, it's just implemented in this test file as
+ LEAPIMAPServer. We will move the implementation, together with
+ authentication bits, to leap.mail.imap.server so it can be instantiated
+ from the tac file.
+
+ Right now this TestCase tries to mimmick as close as possible the
+ organization from the twisted.mail.imap tests so we can achieve
+ a complete implementation. The order in which they appear reflect
+ the intended order of implementation.
+ """
+
+ #
+ # mailboxes operations
+ #
+
+ def testCreate(self):
+ """
+ Test whether we can create mailboxes
+ """
+ succeed = ('testbox', 'test/box', 'test/', 'test/box/box', 'foobox')
+ fail = ('testbox', 'test/box')
+ acc = self.server.theAccount
+
+ def cb():
+ self.result.append(1)
+
+ def eb(failure):
+ self.result.append(0)
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def create():
+ create_deferreds = []
+ for name in succeed + fail:
+ d = self.client.create(name)
+ d.addCallback(strip(cb)).addErrback(eb)
+ create_deferreds.append(d)
+ dd = defer.gatherResults(create_deferreds)
+ dd.addCallbacks(self._cbStopClient, self._ebGeneral)
+ return dd
+
+ self.result = []
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallback(strip(create))
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2], consumeErrors=True)
+ d.addCallback(lambda _: acc.account.list_all_mailbox_names())
+ return d.addCallback(self._cbTestCreate, succeed, fail)
+
+ def _cbTestCreate(self, mailboxes, succeed, fail):
+ self.assertEqual(self.result, [1] * len(succeed) + [0] * len(fail))
+
+ answers = ([u'INBOX', u'testbox', u'test/box', u'test',
+ u'test/box/box', 'foobox'])
+ self.assertEqual(sorted(mailboxes), sorted([a for a in answers]))
+
+ def testDelete(self):
+ """
+ Test whether we can delete mailboxes
+ """
+ def add_mailbox():
+ return self.server.theAccount.addMailbox('test-delete/me')
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def delete():
+ return self.client.delete('test-delete/me')
+
+ acc = self.server.theAccount.account
+
+ d1 = self.connected.addCallback(add_mailbox)
+ d1.addCallback(strip(login))
+ d1.addCallbacks(strip(delete), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: acc.list_all_mailbox_names())
+ d.addCallback(lambda mboxes: self.assertEqual(
+ mboxes, ['INBOX']))
+ return d
+
+ def testIllegalInboxDelete(self):
+ """
+ Test what happens if we try to delete the user Inbox.
+ We expect that operation to fail.
+ """
+ self.stashed = None
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def delete():
+ return self.client.delete('inbox')
+
+ def stash(result):
+ self.stashed = result
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(delete), self._ebGeneral)
+ d1.addBoth(stash)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: self.failUnless(isinstance(self.stashed,
+ failure.Failure)))
+ return d
+
+ def testNonExistentDelete(self):
+ """
+ Test what happens if we try to delete a non-existent mailbox.
+ We expect an error raised stating 'No such mailbox'
+ """
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def delete():
+ return self.client.delete('delete/me')
+ self.failure = failure
+
+ def deleteFailed(failure):
+ self.failure = failure
+
+ self.failure = None
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallback(strip(delete)).addErrback(deleteFailed)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: self.assertTrue(
+ str(self.failure.value).startswith('No such mailbox')))
+ return d
+
+ def testIllegalDelete(self):
+ """
+ Try deleting a mailbox with sub-folders, and \NoSelect flag set.
+ An exception is expected.
+ """
+ acc = self.server.theAccount
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def create_mailboxes():
+ d1 = acc.addMailbox('delete')
+ d2 = acc.addMailbox('delete/me')
+ d = defer.gatherResults([d1, d2])
+ return d
+
+ def get_noselect_mailbox(mboxes):
+ mbox = mboxes[0]
+ return mbox.setFlags((r'\Noselect',))
+
+ def delete_mbox(ignored):
+ return self.client.delete('delete')
+
+ def deleteFailed(failure):
+ self.failure = failure
+
+ self.failure = None
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallback(strip(create_mailboxes))
+ d1.addCallback(get_noselect_mailbox)
+
+ d1.addCallback(delete_mbox).addErrback(deleteFailed)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ expected = ("Hierarchically inferior mailboxes exist "
+ "and \\Noselect is set")
+ d.addCallback(lambda _:
+ self.assertTrue(self.failure is not None))
+ d.addCallback(lambda _:
+ self.assertEqual(str(self.failure.value), expected))
+ return d
+
+ # FIXME --- this test sometimes FAILS (timing issue).
+ # Some of the deferreds used in the rename op is not waiting for the
+ # operations properly
+ def testRename(self):
+ """
+ Test whether we can rename a mailbox
+ """
+ def create_mbox():
+ return self.server.theAccount.addMailbox('oldmbox')
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def rename():
+ return self.client.rename('oldmbox', 'newname')
+
+ d1 = self.connected.addCallback(strip(create_mbox))
+ d1.addCallback(strip(login))
+ d1.addCallbacks(strip(rename), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _:
+ self.server.theAccount.account.list_all_mailbox_names())
+ d.addCallback(lambda mboxes:
+ self.assertItemsEqual(mboxes, ['INBOX', 'newname']))
+ return d
+
+ def testIllegalInboxRename(self):
+ """
+ Try to rename inbox. We expect it to fail. Then it would be not
+ an inbox anymore, would it?
+ """
+ self.stashed = None
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def rename():
+ return self.client.rename('inbox', 'frotz')
+
+ def stash(stuff):
+ self.stashed = stuff
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(rename), self._ebGeneral)
+ d1.addBoth(stash)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _:
+ self.failUnless(isinstance(
+ self.stashed, failure.Failure)))
+ return d
+
+ def testHierarchicalRename(self):
+ """
+ Try to rename hierarchical mailboxes
+ """
+ acc = self.server.theAccount
+
+ def add_mailboxes():
+ return defer.gatherResults([
+ acc.addMailbox('oldmbox/m1'),
+ acc.addMailbox('oldmbox/m2')])
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def rename():
+ return self.client.rename('oldmbox', 'newname')
+
+ d1 = self.connected.addCallback(strip(add_mailboxes))
+ d1.addCallback(strip(login))
+ d1.addCallbacks(strip(rename), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: acc.account.list_all_mailbox_names())
+ return d.addCallback(self._cbTestHierarchicalRename)
+
+ def _cbTestHierarchicalRename(self, mailboxes):
+ expected = ['INBOX', 'newname/m1', 'newname/m2']
+ self.assertEqual(sorted(mailboxes), sorted([s for s in expected]))
+
+ def testSubscribe(self):
+ """
+ Test whether we can mark a mailbox as subscribed to
+ """
+ acc = self.server.theAccount
+
+ def add_mailbox():
+ return acc.addMailbox('this/mbox')
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def subscribe():
+ return self.client.subscribe('this/mbox')
+
+ def get_subscriptions(ignored):
+ return self.server.theAccount.getSubscriptions()
+
+ d1 = self.connected.addCallback(strip(add_mailbox))
+ d1.addCallback(strip(login))
+ d1.addCallbacks(strip(subscribe), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(get_subscriptions)
+ d.addCallback(lambda subscriptions:
+ self.assertEqual(subscriptions,
+ ['this/mbox']))
+ return d
+
+ def testUnsubscribe(self):
+ """
+ Test whether we can unsubscribe from a set of mailboxes
+ """
+ acc = self.server.theAccount
+
+ def add_mailboxes():
+ return defer.gatherResults([
+ acc.addMailbox('this/mbox'),
+ acc.addMailbox('that/mbox')])
+
+ def dc1():
+ return acc.subscribe('this/mbox')
+
+ def dc2():
+ return acc.subscribe('that/mbox')
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def unsubscribe():
+ return self.client.unsubscribe('this/mbox')
+
+ def get_subscriptions(ignored):
+ return acc.getSubscriptions()
+
+ d1 = self.connected.addCallback(strip(add_mailboxes))
+ d1.addCallback(strip(login))
+ d1.addCallback(strip(dc1))
+ d1.addCallback(strip(dc2))
+ d1.addCallbacks(strip(unsubscribe), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(get_subscriptions)
+ d.addCallback(lambda subscriptions:
+ self.assertEqual(subscriptions,
+ ['that/mbox']))
+ return d
+
+ def testSelect(self):
+ """
+ Try to select a mailbox
+ """
+ mbox_name = "TESTMAILBOXSELECT"
+ self.selectedArgs = None
+
+ acc = self.server.theAccount
+
+ def add_mailbox():
+ return acc.addMailbox(mbox_name, creation_ts=42)
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def select():
+ def selected(args):
+ self.selectedArgs = args
+ self._cbStopClient(None)
+ d = self.client.select(mbox_name)
+ d.addCallback(selected)
+ return d
+
+ d1 = self.connected.addCallback(strip(add_mailbox))
+ d1.addCallback(strip(login))
+ d1.addCallback(strip(select))
+ # d1.addErrback(self._ebGeneral)
+
+ d2 = self.loopback()
+
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(self._cbTestSelect)
+ return d
+
+ def _cbTestSelect(self, ignored):
+ self.assertTrue(self.selectedArgs is not None)
+
+ self.assertEqual(self.selectedArgs, {
+ 'EXISTS': 0, 'RECENT': 0, 'UIDVALIDITY': 42,
+ 'FLAGS': ('\\Seen', '\\Answered', '\\Flagged',
+ '\\Deleted', '\\Draft', '\\Recent', 'List'),
+ 'READ-WRITE': True
+ })
+
+ #
+ # capabilities
+ #
+
+ def testCapability(self):
+ caps = {}
+
+ def getCaps():
+ def gotCaps(c):
+ caps.update(c)
+ self.server.transport.loseConnection()
+ return self.client.getCapabilities().addCallback(gotCaps)
+
+ d1 = self.connected
+ d1.addCallback(
+ strip(getCaps)).addErrback(self._ebGeneral)
+
+ d = defer.gatherResults([self.loopback(), d1])
+ expected = {'IMAP4rev1': None, 'NAMESPACE': None, 'LITERAL+': None,
+ 'IDLE': None}
+ d.addCallback(lambda _: self.assertEqual(expected, caps))
+ return d
+
+ def testCapabilityWithAuth(self):
+ caps = {}
+ self.server.challengers[
+ 'CRAM-MD5'] = cred.credentials.CramMD5Credentials
+
+ def getCaps():
+ def gotCaps(c):
+ caps.update(c)
+ self.server.transport.loseConnection()
+ return self.client.getCapabilities().addCallback(gotCaps)
+ d1 = self.connected.addCallback(
+ strip(getCaps)).addErrback(self._ebGeneral)
+
+ d = defer.gatherResults([self.loopback(), d1])
+
+ expCap = {'IMAP4rev1': None, 'NAMESPACE': None,
+ 'IDLE': None, 'LITERAL+': None,
+ 'AUTH': ['CRAM-MD5']}
+
+ d.addCallback(lambda _: self.assertEqual(expCap, caps))
+ return d
+
+ #
+ # authentication
+ #
+
+ def testLogout(self):
+ """
+ Test log out
+ """
+ self.loggedOut = 0
+
+ def logout():
+ def setLoggedOut():
+ self.loggedOut = 1
+ self.client.logout().addCallback(strip(setLoggedOut))
+ self.connected.addCallback(strip(logout)).addErrback(self._ebGeneral)
+ d = self.loopback()
+ return d.addCallback(lambda _: self.assertEqual(self.loggedOut, 1))
+
+ def testNoop(self):
+ """
+ Test noop command
+ """
+ self.responses = None
+
+ def noop():
+ def setResponses(responses):
+ self.responses = responses
+ self.server.transport.loseConnection()
+ self.client.noop().addCallback(setResponses)
+ self.connected.addCallback(strip(noop)).addErrback(self._ebGeneral)
+ d = self.loopback()
+ return d.addCallback(lambda _: self.assertEqual(self.responses, []))
+
+ def testLogin(self):
+ """
+ Test login
+ """
+ def login():
+ d = self.client.login(TEST_USER, TEST_PASSWD)
+ d.addCallback(self._cbStopClient)
+ d1 = self.connected.addCallback(
+ strip(login)).addErrback(self._ebGeneral)
+ d = defer.gatherResults([d1, self.loopback()])
+ return d.addCallback(self._cbTestLogin)
+
+ def _cbTestLogin(self, ignored):
+ self.assertEqual(self.server.state, 'auth')
+
+ def testFailedLogin(self):
+ """
+ Test bad login
+ """
+ def login():
+ d = self.client.login("bad_user@leap.se", TEST_PASSWD)
+ d.addBoth(self._cbStopClient)
+
+ d1 = self.connected.addCallback(
+ strip(login)).addErrback(self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ return d.addCallback(self._cbTestFailedLogin)
+
+ def _cbTestFailedLogin(self, ignored):
+ self.assertEqual(self.server.state, 'unauth')
+ self.assertEqual(self.server.account, None)
+
+ def testLoginRequiringQuoting(self):
+ """
+ Test login requiring quoting
+ """
+ self.server.checker.userid = '{test}user@leap.se'
+ self.server.checker.password = '{test}password'
+
+ def login():
+ d = self.client.login('{test}user@leap.se', '{test}password')
+ d.addBoth(self._cbStopClient)
+
+ d1 = self.connected.addCallback(
+ strip(login)).addErrback(self._ebGeneral)
+ d = defer.gatherResults([self.loopback(), d1])
+ return d.addCallback(self._cbTestLoginRequiringQuoting)
+
+ def _cbTestLoginRequiringQuoting(self, ignored):
+ self.assertEqual(self.server.state, 'auth')
+
+ #
+ # Inspection
+ #
+
+ def testNamespace(self):
+ """
+ Test retrieving namespace
+ """
+ self.namespaceArgs = None
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def namespace():
+ def gotNamespace(args):
+ self.namespaceArgs = args
+ self._cbStopClient(None)
+ return self.client.namespace().addCallback(gotNamespace)
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallback(strip(namespace))
+ d1.addErrback(self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: self.assertEqual(self.namespaceArgs,
+ [[['', '/']], [], []]))
+ return d
+
+ def testExamine(self):
+ """
+ L{IMAP4Client.examine} issues an I{EXAMINE} command to the server and
+ returns a L{Deferred} which fires with a C{dict} with as many of the
+ following keys as the server includes in its response: C{'FLAGS'},
+ C{'EXISTS'}, C{'RECENT'}, C{'UNSEEN'}, C{'READ-WRITE'}, C{'READ-ONLY'},
+ C{'UIDVALIDITY'}, and C{'PERMANENTFLAGS'}.
+
+ Unfortunately the server doesn't generate all of these so it's hard to
+ test the client's handling of them here. See
+ L{IMAP4ClientExamineTests} below.
+
+ See U{RFC 3501<http://www.faqs.org/rfcs/rfc3501.html>}, section 6.3.2,
+ for details.
+ """
+ # TODO implement the IMAP4ClientExamineTests testcase.
+ mbox_name = "test_mailbox_e"
+ acc = self.server.theAccount
+ self.examinedArgs = None
+
+ def add_mailbox():
+ return acc.addMailbox(mbox_name, creation_ts=42)
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def examine():
+ def examined(args):
+ self.examinedArgs = args
+ self._cbStopClient(None)
+ d = self.client.examine(mbox_name)
+ d.addCallback(examined)
+ return d
+
+ d1 = self.connected.addCallback(strip(add_mailbox))
+ d1.addCallback(strip(login))
+ d1.addCallback(strip(examine))
+ d1.addErrback(self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ return d.addCallback(self._cbTestExamine)
+
+ def _cbTestExamine(self, ignored):
+ self.assertEqual(self.examinedArgs, {
+ 'EXISTS': 0, 'RECENT': 0, 'UIDVALIDITY': 42,
+ 'FLAGS': ('\\Seen', '\\Answered', '\\Flagged',
+ '\\Deleted', '\\Draft', '\\Recent', 'List'),
+ 'READ-WRITE': False})
+
+ def _listSetup(self, f, f2=None):
+
+ acc = self.server.theAccount
+
+ def dc1():
+ return acc.addMailbox('root_subthing', creation_ts=42)
+
+ def dc2():
+ return acc.addMailbox('root_another_thing', creation_ts=42)
+
+ def dc3():
+ return acc.addMailbox('non_root_subthing', creation_ts=42)
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def listed(answers):
+ self.listed = answers
+
+ self.listed = None
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallback(strip(dc1))
+ d1.addCallback(strip(dc2))
+ d1.addCallback(strip(dc3))
+
+ if f2 is not None:
+ d1.addCallback(f2)
+
+ d1.addCallbacks(strip(f), self._ebGeneral)
+ d1.addCallbacks(listed, self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ return defer.gatherResults([d1, d2]).addCallback(lambda _: self.listed)
+
+ def testList(self):
+ """
+ Test List command
+ """
+ def list():
+ return self.client.list('root', '%')
+
+ d = self._listSetup(list)
+ d.addCallback(lambda listed: self.assertEqual(
+ sortNest(listed),
+ sortNest([
+ (IMAPMailbox.init_flags, "/", "root_subthing"),
+ (IMAPMailbox.init_flags, "/", "root_another_thing")
+ ])
+ ))
+ return d
+
+ def testLSub(self):
+ """
+ Test LSub command
+ """
+ acc = self.server.theAccount
+
+ def subs_mailbox():
+ # why not client.subscribe instead?
+ return acc.subscribe('root_subthing')
+
+ def lsub():
+ return self.client.lsub('root', '%')
+
+ d = self._listSetup(lsub, strip(subs_mailbox))
+ d.addCallback(self.assertEqual,
+ [(IMAPMailbox.init_flags, "/", "root_subthing")])
+ return d
+
+ def testStatus(self):
+ """
+ Test Status command
+ """
+ acc = self.server.theAccount
+
+ def add_mailbox():
+ return acc.addMailbox('root_subthings')
+
+ # XXX FIXME ---- should populate this a little bit,
+ # with unseen etc...
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def status():
+ return self.client.status(
+ 'root_subthings', 'MESSAGES', 'UIDNEXT', 'UNSEEN')
+
+ def statused(result):
+ self.statused = result
+
+ self.statused = None
+
+ d1 = self.connected.addCallback(strip(add_mailbox))
+ d1.addCallback(strip(login))
+ d1.addCallbacks(strip(status), self._ebGeneral)
+ d1.addCallbacks(statused, self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: self.assertEqual(
+ self.statused,
+ {'MESSAGES': 0, 'UIDNEXT': '1', 'UNSEEN': 0}
+ ))
+ return d
+
+ def testFailedStatus(self):
+ """
+ Test failed status command with a non-existent mailbox
+ """
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def status():
+ return self.client.status(
+ 'root/nonexistent', 'MESSAGES', 'UIDNEXT', 'UNSEEN')
+
+ def statused(result):
+ self.statused = result
+
+ def failed(failure):
+ self.failure = failure
+
+ self.statused = self.failure = None
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(status), self._ebGeneral)
+ d1.addCallbacks(statused, failed)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ return defer.gatherResults([d1, d2]).addCallback(
+ self._cbTestFailedStatus)
+
+ def _cbTestFailedStatus(self, ignored):
+ self.assertEqual(
+ self.statused, None
+ )
+ self.assertEqual(
+ self.failure.value.args,
+ ('Could not open mailbox',)
+ )
+
+ #
+ # messages
+ #
+
+ def testFullAppend(self):
+ """
+ Test appending a full message to the mailbox
+ """
+ infile = util.sibpath(__file__, 'rfc822.message')
+ message = open(infile)
+ acc = self.server.theAccount
+ mailbox_name = "appendmbox/subthing"
+
+ def add_mailbox():
+ return acc.addMailbox(mailbox_name)
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def append():
+ return self.client.append(
+ mailbox_name, message,
+ ('\\SEEN', '\\DELETED'),
+ 'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)',
+ )
+
+ d1 = self.connected.addCallback(strip(add_mailbox))
+ d1.addCallback(strip(login))
+ d1.addCallbacks(strip(append), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+
+ d.addCallback(lambda _: acc.getMailbox(mailbox_name))
+ d.addCallback(lambda mb: mb.fetch(imap4.MessageSet(start=1), True))
+ return d.addCallback(self._cbTestFullAppend, infile)
+
+ def _cbTestFullAppend(self, fetched, infile):
+ fetched = list(fetched)
+ self.assertTrue(len(fetched) == 1)
+ self.assertTrue(len(fetched[0]) == 2)
+ uid, msg = fetched[0]
+ parsed = self.parser.parse(open(infile))
+ expected_body = parsed.get_payload()
+ expected_headers = CaseInsensitiveDict(parsed.items())
+
+ def assert_flags(flags):
+ self.assertEqual(
+ set(('\\SEEN', '\\DELETED')),
+ set(flags))
+
+ def assert_date(date):
+ self.assertEqual(
+ 'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)',
+ date)
+
+ def assert_body(body):
+ gotbody = body.read()
+ self.assertEqual(expected_body, gotbody)
+
+ def assert_headers(headers):
+ self.assertItemsEqual(map(string.lower, expected_headers), headers)
+
+ d = defer.maybeDeferred(msg.getFlags)
+ d.addCallback(assert_flags)
+
+ d.addCallback(lambda _: defer.maybeDeferred(msg.getInternalDate))
+ d.addCallback(assert_date)
+
+ d.addCallback(
+ lambda _: defer.maybeDeferred(
+ msg.getBodyFile, self._soledad))
+ d.addCallback(assert_body)
+
+ d.addCallback(lambda _: defer.maybeDeferred(msg.getHeaders, True))
+ d.addCallback(assert_headers)
+
+ return d
+
+ def testPartialAppend(self):
+ """
+ Test partially appending a message to the mailbox
+ """
+ # TODO this test sometimes will fail because of the notify_just_mdoc
+ infile = util.sibpath(__file__, 'rfc822.message')
+
+ acc = self.server.theAccount
+
+ def add_mailbox():
+ return acc.addMailbox('PARTIAL/SUBTHING')
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def append():
+ message = file(infile)
+ return self.client.sendCommand(
+ imap4.Command(
+ 'APPEND',
+ 'PARTIAL/SUBTHING (\\SEEN) "Right now" '
+ '{%d}' % os.path.getsize(infile),
+ (), self.client._IMAP4Client__cbContinueAppend, message
+ )
+ )
+ d1 = self.connected.addCallback(strip(add_mailbox))
+ d1.addCallback(strip(login))
+ d1.addCallbacks(strip(append), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+
+ d.addCallback(lambda _: acc.getMailbox("PARTIAL/SUBTHING"))
+ d.addCallback(lambda mb: mb.fetch(imap4.MessageSet(start=1), True))
+ return d.addCallback(
+ self._cbTestPartialAppend, infile)
+
+ def _cbTestPartialAppend(self, fetched, infile):
+ fetched = list(fetched)
+ self.assertTrue(len(fetched) == 1)
+ self.assertTrue(len(fetched[0]) == 2)
+ uid, msg = fetched[0]
+ parsed = self.parser.parse(open(infile))
+ expected_body = parsed.get_payload()
+
+ def assert_flags(flags):
+ self.assertEqual(
+ set((['\\SEEN'])), set(flags))
+
+ def assert_body(body):
+ gotbody = body.read()
+ self.assertEqual(expected_body, gotbody)
+
+ d = defer.maybeDeferred(msg.getFlags)
+ d.addCallback(assert_flags)
+
+ d.addCallback(lambda _: defer.maybeDeferred(msg.getBodyFile))
+ d.addCallback(assert_body)
+ return d
+
+ def testCheck(self):
+ """
+ Test check command
+ """
+ def add_mailbox():
+ return self.server.theAccount.addMailbox('root/subthing')
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def select():
+ return self.client.select('root/subthing')
+
+ def check():
+ return self.client.check()
+
+ d = self.connected.addCallbacks(
+ strip(add_mailbox), self._ebGeneral)
+ d.addCallbacks(lambda _: login(), self._ebGeneral)
+ d.addCallbacks(strip(select), self._ebGeneral)
+ d.addCallbacks(strip(check), self._ebGeneral)
+ d.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ return defer.gatherResults([d, d2])
+
+ # Okay, that was much fun indeed
+
+ def testExpunge(self):
+ """
+ Test expunge command
+ """
+ acc = self.server.theAccount
+ mailbox_name = 'mailboxexpunge'
+
+ def add_mailbox():
+ return acc.addMailbox(mailbox_name)
+
+ def login():
+ return self.client.login(TEST_USER, TEST_PASSWD)
+
+ def select():
+ return self.client.select(mailbox_name)
+
+ def save_mailbox(mailbox):
+ self.mailbox = mailbox
+
+ def get_mailbox():
+ d = acc.getMailbox(mailbox_name)
+ d.addCallback(save_mailbox)
+ return d
+
+ def add_messages():
+ d = self.mailbox.addMessage(
+ 'test 1', flags=('\\Deleted', 'AnotherFlag'),
+ notify_just_mdoc=False)
+ d.addCallback(lambda _: self.mailbox.addMessage(
+ 'test 2', flags=('AnotherFlag',),
+ notify_just_mdoc=False))
+ d.addCallback(lambda _: self.mailbox.addMessage(
+ 'test 3', flags=('\\Deleted',),
+ notify_just_mdoc=False))
+ return d
+
+ def expunge():
+ return self.client.expunge()
+
+ def expunged(results):
+ self.failIf(self.server.mbox is None)
+ self.results = results
+
+ self.results = None
+ d1 = self.connected.addCallback(strip(add_mailbox))
+ d1.addCallback(strip(login))
+ d1.addCallback(strip(get_mailbox))
+ d1.addCallbacks(strip(add_messages), self._ebGeneral)
+ d1.addCallbacks(strip(select), self._ebGeneral)
+ d1.addCallbacks(strip(expunge), self._ebGeneral)
+ d1.addCallbacks(expunged, self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: self.mailbox.getMessageCount())
+ return d.addCallback(self._cbTestExpunge)
+
+ def _cbTestExpunge(self, count):
+ # we only left 1 mssage with no deleted flag
+ self.assertEqual(count, 1)
+ # the uids of the deleted messages
+ self.assertItemsEqual(self.results, [1, 3])
+
+
+class AccountTestCase(IMAP4HelperMixin):
+ """
+ Test the Account.
+ """
+ def _create_empty_mailbox(self):
+ return self.server.theAccount.addMailbox('')
+
+ def _create_one_mailbox(self):
+ return self.server.theAccount.addMailbox('one')
+
+ def test_illegalMailboxCreate(self):
+ self.assertRaises(AssertionError, self._create_empty_mailbox)
+
+
+class IMAP4ServerSearchTestCase(IMAP4HelperMixin):
+ """
+ Tests for the behavior of the search_* functions in L{imap5.IMAP4Server}.
+ """
+ # XXX coming soon to your screens!
+ pass
diff --git a/src/leap/bitmask/mail/imap/tests/walktree.py b/src/leap/bitmask/mail/imap/tests/walktree.py
new file mode 100644
index 0000000..f259a55
--- /dev/null
+++ b/src/leap/bitmask/mail/imap/tests/walktree.py
@@ -0,0 +1,127 @@
+# -*- coding: utf-8 -*-
+# walktree.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Tests for the walktree module.
+"""
+import os
+import sys
+import pprint
+from email import parser
+
+from leap.mail import walk as W
+
+DEBUG = os.environ.get("BITMASK_MAIL_DEBUG")
+
+
+p = parser.Parser()
+
+# TODO pass an argument of the type of message
+
+##################################################
+# Input from hell
+
+if len(sys.argv) > 1:
+ FILENAME = sys.argv[1]
+else:
+ FILENAME = "rfc822.multi-signed.message"
+
+"""
+FILENAME = "rfc822.plain.message"
+FILENAME = "rfc822.multi-minimal.message"
+"""
+
+msg = p.parse(open(FILENAME))
+DO_CHECK = False
+#################################################
+
+parts = W.get_parts(msg)
+
+if DEBUG:
+ def trim(item):
+ item = item[:10]
+ [trim(part["phash"]) for part in parts if part.get('phash', None)]
+
+raw_docs = list(W.get_raw_docs(msg, parts))
+
+body_phash_fun = [W.get_body_phash_simple,
+ W.get_body_phash_multi][int(msg.is_multipart())]
+body_phash = body_phash_fun(W.get_payloads(msg))
+parts_map = W.walk_msg_tree(parts, body_phash=body_phash)
+
+
+# TODO add missing headers!
+expected = {
+ 'body': '1ddfa80485',
+ 'multi': True,
+ 'part_map': {
+ 1: {
+ 'headers': {'Content-Disposition': 'inline',
+ 'Content-Type': 'multipart/mixed; '
+ 'boundary="z0eOaCaDLjvTGF2l"'},
+ 'multi': True,
+ 'part_map': {1: {'ctype': 'text/plain',
+ 'headers': [
+ ('Content-Type',
+ 'text/plain; charset=utf-8'),
+ ('Content-Disposition',
+ 'inline'),
+ ('Content-Transfer-Encoding',
+ 'quoted-printable')],
+ 'multi': False,
+ 'parts': 1,
+ 'phash': '1ddfa80485',
+ 'size': 206},
+ 2: {'ctype': 'text/plain',
+ 'headers': [('Content-Type',
+ 'text/plain; charset=us-ascii'),
+ ('Content-Disposition',
+ 'attachment; '
+ 'filename="attach.txt"')],
+ 'multi': False,
+ 'parts': 1,
+ 'phash': '7a94e4d769',
+ 'size': 133},
+ 3: {'ctype': 'application/octet-stream',
+ 'headers': [('Content-Type',
+ 'application/octet-stream'),
+ ('Content-Disposition',
+ 'attachment; filename="hack.ico"'),
+ ('Content-Transfer-Encoding',
+ 'base64')],
+ 'multi': False,
+ 'parts': 1,
+ 'phash': 'c42cccebbd',
+ 'size': 12736}}},
+ 2: {'ctype': 'application/pgp-signature',
+ 'headers': [('Content-Type', 'application/pgp-signature')],
+ 'multi': False,
+ 'parts': 1,
+ 'phash': '8f49fbf749',
+ 'size': 877}}}
+
+if DEBUG and DO_CHECK:
+ # TODO turn this into a proper unittest
+ assert(parts_map == expected)
+ print "Structure: OK"
+
+
+print
+print "RAW DOCS"
+pprint.pprint(raw_docs)
+print
+print "PARTS MAP"
+pprint.pprint(parts_map)