diff options
| -rw-r--r-- | mail/README.rst | 8 | ||||
| -rw-r--r-- | mail/pkg/requirements.pip | 4 | ||||
| -rw-r--r-- | mail/src/leap/mail/imap/fetch.py | 128 | ||||
| -rw-r--r-- | mail/src/leap/mail/imap/server.py | 1351 | ||||
| -rw-r--r-- | mail/src/leap/mail/imap/service/README.rst | 39 | ||||
| -rw-r--r-- | mail/src/leap/mail/imap/service/imap-server.tac | 230 | ||||
| -rw-r--r-- | mail/src/leap/mail/imap/service/notes.txt | 81 | ||||
| -rw-r--r-- | mail/src/leap/mail/imap/service/rfc822.message | 86 | ||||
| -rw-r--r-- | mail/src/leap/mail/imap/tests/__init__.py | 15 | ||||
| -rw-r--r-- | mail/src/leap/mail/imap/tests/test_imap.py | 955 | 
10 files changed, 2222 insertions, 675 deletions
| diff --git a/mail/README.rst b/mail/README.rst index 92a4fa6..7224cba 100644 --- a/mail/README.rst +++ b/mail/README.rst @@ -1,5 +1,11 @@  leap.mail  ========= -Mail services for the LEAP CLient. +Mail services for the LEAP Client.  More info: https://leap.se + +running tests +------------- + +* nosetests --with-progressive leap.mail.imap.test_imap +* trial leap.mail.smtp diff --git a/mail/pkg/requirements.pip b/mail/pkg/requirements.pip index 1b5e5ef..af633f9 100644 --- a/mail/pkg/requirements.pip +++ b/mail/pkg/requirements.pip @@ -1,3 +1,3 @@ -leap.common -leap.soledad +leap.common>=0.2.3-dev +leap.soledad>=0.0.2-dev  twisted diff --git a/mail/src/leap/mail/imap/fetch.py b/mail/src/leap/mail/imap/fetch.py new file mode 100644 index 0000000..adf5787 --- /dev/null +++ b/mail/src/leap/mail/imap/fetch.py @@ -0,0 +1,128 @@ +import json +import os +#import hmac + +from xdg import BaseDirectory + +from twisted.python import log + +from leap.common.check import leap_assert +from leap.soledad import Soledad + +from leap.common.keymanager import openpgp + + +class LeapIncomingMail(object): +    """ +    Fetches mail from the incoming queue. +    """ +    def __init__(self, keymanager, user_uuid, soledad_pass, server_url, +                 server_pemfile, token, imap_account, +                 **kwargs): +        """ +        Initialize LeapIMAP. + +        :param user: The user adress in the form C{user@provider}. +        :type user: str + +        :param soledad_pass: The password for the local database replica. +        :type soledad_pass: str + +        :param server_url: The URL of the remote server to sync against. +        :type couch_url: str + +        :param server_pemfile: The pemfile for the remote sync server TLS +                               handshake. +        :type server_pemfile: str + +        :param token: a session token valid for this user. +        :type token: str + +        :param imap_account: a SoledadBackedAccount instance to which +                             the incoming mail will be saved to + +        :param **kwargs: Used to pass arguments to Soledad instance. Maybe +            Soledad instantiation could be factored out from here, and maybe +            we should have a standard for all client code. +        """ +        leap_assert(user_uuid, "need an user uuid to initialize") + +        self._keymanager = keymanager +        self._user_uuid = user_uuid +        self._server_url = server_url +        self._soledad_pass = soledad_pass + +        base_config = BaseDirectory.xdg_config_home +        secret_path = os.path.join( +            base_config, "leap", "soledad", "%s.secret" % user_uuid) +        soledad_path = os.path.join( +            base_config, "leap", "soledad", "%s-incoming.u1db" % user_uuid) + +        self.imapAccount = imap_account +        self._soledad = Soledad( +            user_uuid, +            soledad_pass, +            secret_path, +            soledad_path, +            server_url, +            server_pemfile, +            token, +            bootstrap=True) + +        self._pkey = self._keymanager.get_all_keys_in_local_db( +            private=True).pop() +        log.msg('fetcher got soledad instance') + +    def fetch(self): +        """ +        Get new mail by syncing database, store it in the INBOX for the +        user account, and remove from the incoming db. +        """ +        self._soledad.sync() + +        #log.msg('getting all docs') +        gen, doclist = self._soledad.get_all_docs() +        #log.msg("there are %s docs" % (len(doclist),)) + +        if doclist: +            inbox = self.imapAccount.getMailbox('inbox') + +        #import ipdb; ipdb.set_trace() + +        key = self._pkey +        for doc in doclist: +            keys = doc.content.keys() +            if '_enc_scheme' in keys and '_enc_json' in keys: + +                # XXX should check for _enc_scheme == "pubkey" || "none" +                # that is what incoming mail uses. + +                encdata = doc.content['_enc_json'] +                decrdata = openpgp.decrypt_asym( +                    encdata, key, +                    passphrase=self._soledad_pass) +                if decrdata: +                    self.process_decrypted(doc, decrdata, inbox) +        # XXX launch sync callback + +    def process_decrypted(self, doc, data, inbox): +        """ +        Process a successfully decrypted message +        """ +        log.msg("processing message!") +        msg = json.loads(data) +        if not isinstance(msg, dict): +            return False +        if not msg.get('incoming', False): +            return False +        # ok, this is an incoming message +        rawmsg = msg.get('content', None) +        if not rawmsg: +            return False +        log.msg("we got raw message") + +        # add to inbox and delete from soledad +        inbox.addMessage(rawmsg, ("\\Recent",)) +        log.msg("added msg") +        self._soledad.delete_doc(doc) +        log.msg("deleted doc") diff --git a/mail/src/leap/mail/imap/server.py b/mail/src/leap/mail/imap/server.py index 4e9c22c..c8eac71 100644 --- a/mail/src/leap/mail/imap/server.py +++ b/mail/src/leap/mail/imap/server.py @@ -1,97 +1,45 @@ +# -*- coding: utf-8 -*- +# server.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/>. +""" +Soledad-backed IMAP Server. +"""  import copy +import logging +import StringIO +import cStringIO +import time + +from email.parser import Parser  from zope.interface import implements  from twisted.mail import imap4  from twisted.internet import defer +from twisted.python import log  #from twisted import cred -import u1db - - -# TODO delete this SimpleMailbox -class SimpleMailbox: -    """ -    A simple Mailbox for reference -    We don't intend to use this, only for debugging purposes -    until we stabilize unittests with SoledadMailbox -    """ -    implements(imap4.IMailboxInfo, imap4.IMailbox, imap4.ICloseableMailbox) - -    flags = ('\\Flag1', 'Flag2', '\\AnotherSysFlag', 'LastFlag') -    messages = [] -    mUID = 0 -    rw = 1 -    closed = False - -    def __init__(self): -        self.listeners = [] -        self.addListener = self.listeners.append -        self.removeListener = self.listeners.remove - -    def getFlags(self): -        return self.flags - -    def getUIDValidity(self): -        return 42 - -    def getUIDNext(self): -        return len(self.messages) + 1 - -    def getMessageCount(self): -        return 9 - -    def getRecentCount(self): -        return 3 - -    def getUnseenCount(self): -        return 4 - -    def isWriteable(self): -        return self.rw - -    def destroy(self): -        pass - -    def getHierarchicalDelimiter(self): -        return '/' - -    def requestStatus(self, names): -        r = {} -        if 'MESSAGES' in names: -            r['MESSAGES'] = self.getMessageCount() -        if 'RECENT' in names: -            r['RECENT'] = self.getRecentCount() -        if 'UIDNEXT' in names: -            r['UIDNEXT'] = self.getMessageCount() + 1 -        if 'UIDVALIDITY' in names: -            r['UIDVALIDITY'] = self.getUID() -        if 'UNSEEN' in names: -            r['UNSEEN'] = self.getUnseenCount() -        return defer.succeed(r) - -    def addMessage(self, message, flags, date=None): -        self.messages.append((message, flags, date, self.mUID)) -        self.mUID += 1 -        return defer.succeed(None) - -    def expunge(self): -        delete = [] -        for i in self.messages: -            if '\\Deleted' in i[1]: -                delete.append(i) -        for i in delete: -            self.messages.remove(i) -        return [i[3] for i in delete] +#import u1db -    def close(self): -        self.closed = True +from leap.common.check import leap_assert, leap_assert_type +from leap.soledad.backends.sqlcipher import SQLCipherDatabase +logger = logging.getLogger(__name__) -################################### -# SoledadAccount Index -###################################  class MissingIndexError(Exception):      """raises when tried to access a non existent index document""" @@ -101,153 +49,207 @@ class BadIndexError(Exception):      """raises when index is malformed or has the wrong cardinality""" -EMPTY_INDEXDOC = {"is_index": True, "mailboxes": [], "subscriptions": []} -get_empty_indexdoc = lambda: copy.deepcopy(EMPTY_INDEXDOC) - - -class SoledadAccountIndex(object): +class IndexedDB(object):      """ -    Index for the Soledad Account -    keeps track of mailboxes and subscriptions +    Methods dealing with the index      """ -    _index = None -    def __init__(self, soledad=None): -        self._soledad = soledad -        self._db = soledad._db -        self._initialize_db() - -    def _initialize_db(self): -        """initialize the database""" -        db_indexes = dict(self._soledad._db.list_indexes()) -        name, expression = "isindex", ["bool(is_index)"] -        if name not in db_indexes: -            self._soledad._db.create_index(name, *expression) -        try: -            self._index = self._get_index_doc() -        except MissingIndexError: -            print "no index!!! creating..." -            self._create_index_doc() - -    def _create_index_doc(self): -        """creates an empty index document""" -        indexdoc = get_empty_indexdoc() -        self._index = self._soledad.create_doc( -            indexdoc) - -    def _get_index_doc(self): -        """gets index document""" -        indexdoc = self._db.get_from_index("isindex", "*") -        if not indexdoc: -            raise MissingIndexError -        if len(indexdoc) > 1: -            raise BadIndexError -        return indexdoc[0] - -    def _update_index_doc(self): -        """updates index document""" -        self._db.put_doc(self._index) - -    # setters and getters for the index document - -    def _get_mailboxes(self): -        """Get mailboxes associated with this account.""" -        return self._index.content.setdefault('mailboxes', []) - -    def _set_mailboxes(self, mailboxes): -        """Set mailboxes associated with this account.""" -        self._index.content['mailboxes'] = list(set(mailboxes)) -        self._update_index_doc() - -    mailboxes = property( -        _get_mailboxes, _set_mailboxes, doc="Account mailboxes.") - -    def _get_subscriptions(self): -        """Get subscriptions associated with this account.""" -        return self._index.content.setdefault('subscriptions', []) - -    def _set_subscriptions(self, subscriptions): -        """Set subscriptions associated with this account.""" -        self._index.content['subscriptions'] = list(set(subscriptions)) -        self._update_index_doc() - -    subscriptions = property( -        _get_subscriptions, _set_subscriptions, doc="Account subscriptions.") - -    def addMailbox(self, name): -        """add a mailbox to the mailboxes list.""" -        name = name.upper() -        self.mailboxes.append(name) -        self._update_index_doc() - -    def removeMailbox(self, name): -        """remove a mailbox from the mailboxes list.""" -        self.mailboxes.remove(name) -        self._update_index_doc() - -    def addSubscription(self, name): -        """add a subscription to the subscriptions list.""" -        name = name.upper() -        self.subscriptions.append(name) -        self._update_index_doc() +    def initialize_db(self): +        """ +        Initialize the database. +        """ +        # Ask the database for currently existing indexes. +        db_indexes = dict(self._db.list_indexes()) +        for name, expression in self.INDEXES.items(): +            if name not in db_indexes: +                # The index does not yet exist. +                self._db.create_index(name, *expression) +                continue -    def removeSubscription(self, name): -        """remove a subscription from the subscriptions list.""" -        self.subscriptions.remove(name) -        self._update_index_doc() +            if expression == db_indexes[name]: +                # The index exists and is up to date. +                continue +            # The index exists but the definition is not what expected, so we +            # delete it and add the proper index expression. +            self._db.delete_index(name) +            self._db.create_index(name, *expression)  #######################################  # Soledad Account  ####################################### -class SoledadBackedAccount(object): -    implements(imap4.IAccount, imap4.INamespacePresenter) +class SoledadBackedAccount(IndexedDB): +    """ +    An implementation of IAccount and INamespacePresenteer +    that is backed by Soledad Encrypted Documents. +    """ -    #mailboxes = None -    #subscriptions = None +    implements(imap4.IAccount, imap4.INamespacePresenter) -    top_id = 0  # XXX move top_id to _index      _soledad = None      _db = None +    selected = None + +    TYPE_IDX = 'by-type' +    TYPE_MBOX_IDX = 'by-type-and-mbox' +    TYPE_MBOX_UID_IDX = 'by-type-and-mbox-and-uid' +    TYPE_SUBS_IDX = 'by-type-and-subscribed' +    TYPE_MBOX_SEEN_IDX = 'by-type-and-mbox-and-seen' +    TYPE_MBOX_RECT_IDX = 'by-type-and-mbox-and-recent' + +    INDEXES = { +        # generic +        TYPE_IDX: ['type'], +        TYPE_MBOX_IDX: ['type', 'mbox'], +        TYPE_MBOX_UID_IDX: ['type', 'mbox', 'uid'], + +        # mailboxes +        TYPE_SUBS_IDX: ['type', 'bool(subscribed)'], + +        # messages +        TYPE_MBOX_SEEN_IDX: ['type', 'mbox', 'bool(seen)'], +        TYPE_MBOX_RECT_IDX: ['type', 'mbox', 'bool(recent)'], +    } + +    EMPTY_MBOX = { +        "type": "mbox", +        "mbox": "INBOX", +        "subject": "", +        "flags": [], +        "closed": False, +        "subscribed": False, +        "rw": 1, +    }      def __init__(self, name, soledad=None): -        self.name = name +        """ +        SoledadBackedAccount constructor +        creates a SoledadAccountIndex that keeps track of the +        mailboxes and subscriptions handled by this account. + +        @param name: the name of the account (user id) +        @type name: C{str} + +        @param soledad: a Soledad instance +        @param soledad: C{Soledad} +        """ +        leap_assert(soledad, "Need a soledad instance to initialize") +        # XXX check isinstance ... +        # XXX SHOULD assert too that the name matches the user with which +        # soledad has been intialized. + +        self.name = name.upper()          self._soledad = soledad +          self._db = soledad._db -        self._index = SoledadAccountIndex(soledad=soledad) +        self.initialize_db() + +        # every user should see an inbox folder +        # at least -        #self.mailboxes = {} -        #self.subscriptions = [] +        if not self.mailboxes: +            self.addMailbox('inbox') -    def allocateID(self): -        id = self.top_id  # XXX move to index !!! -        self.top_id += 1 -        return id +    def _get_empty_mailbox(self): +        """ +        Returns an empty mailbox. + +        @rtype: dict +        """ +        return copy.deepcopy(self.EMPTY_MBOX) + +    def _get_mailbox_by_name(self, name): +        """ +        Returns an mbox by name. + +        @rtype: C{LeapDocument} +        """ +        name = name.upper() +        doc = self._db.get_from_index(self.TYPE_MBOX_IDX, 'mbox', name) +        return doc[0] if doc else None      @property      def mailboxes(self): -        return self._index.mailboxes +        """ +        A list of the current mailboxes for this account. +        """ +        return [str(doc.content['mbox']) +                for doc in self._db.get_from_index(self.TYPE_IDX, 'mbox')]      @property      def subscriptions(self): -        return self._index.subscriptions +        """ +        A list of the current subscriptions for this account. +        """ +        return [str(doc.content['mbox']) +                for doc in self._db.get_from_index( +                    self.TYPE_SUBS_IDX, 'mbox', '1')] + +    def getMailbox(self, name): +        """ +        Returns Mailbox with that name, without selecting it. + +        @param name: name of the mailbox +        @type name: C{str} + +        @returns: a a SoledadMailbox instance +        """ +        name = name.upper() +        if name not in self.mailboxes: +            raise imap4.MailboxException("No such mailbox") + +        return SoledadMailbox(name, soledad=self._soledad)      ##      ## IAccount      ## -    def addMailbox(self, name, mbox=None): +    def addMailbox(self, name, creation_ts=None): +        """ +        Adds a mailbox to the account. + +        @param name: the name of the mailbox +        @type name: str + +        @param creation_ts: a optional creation timestamp to be used as +            mailbox id. A timestamp will be used if no one is provided. +        @type creation_ts: C{int} + +        @returns: True if successful +        @rtype: bool +        """          name = name.upper() +        # XXX should check mailbox name for RFC-compliant form +          if name in self.mailboxes:              raise imap4.MailboxCollision, name -        if mbox is None: -            mbox = self._emptyMailbox(name, self.allocateID()) -        self._index.addMailbox(name) -        return 1 + +        if not creation_ts: +            # by default, we pass an int value +            # taken from the current time +            creation_ts = int(time.time() * 10E2) + +        mbox = self._get_empty_mailbox() +        mbox['mbox'] = name +        mbox['created'] = creation_ts + +        doc = self._db.create_doc(mbox) +        return bool(doc)      def create(self, pathspec): +        # XXX What _exactly_ is the difference with addMailbox? +        # We accept here a path specification, which can contain +        # many levels, but look for the appropriate documentation +        # pointer. +        """ +        Create a mailbox +        Return True if successfully created + +        @param pathspec: XXX ??? ----------------- +        @rtype: bool +        """          paths = filter(None, pathspec.split('/'))          for accum in range(1, len(paths)):              try: @@ -261,38 +263,68 @@ class SoledadBackedAccount(object):                  return False          return True -    def _emptyMailbox(self, name, id): -        # XXX implement!!! -        raise NotImplementedError -      def select(self, name, readwrite=1): -        return self.mailboxes.get(name.upper()) +        """ +        Select a mailbox. +        @param name: the mailbox to select +        @param readwrite: 1 for readwrite permissions. +        @rtype: bool +        """ +        name = name.upper() + +        if name not in self.mailboxes: +            return None + +        self.selected = str(name) -    def delete(self, name): +        return SoledadMailbox( +            name, rw=readwrite, +            soledad=self._soledad) + +    def delete(self, name, force=False): +        """ +        Deletes a mailbox. +        Right now it does not purge the messages, but just removes the mailbox +        name from the mailboxes list!!! + +        @param name: the mailbox to be deleted +        """          name = name.upper() -        # See if this mailbox exists at all -        mbox = self.mailboxes.get(name) -        if not mbox: +        if not name in self.mailboxes:              raise imap4.MailboxException("No such mailbox") -        # See if this box is flagged \Noselect -        if r'\Noselect' in mbox.getFlags(): -            # Check for hierarchically inferior mailboxes with this one -            # as part of their root. -            for others in self.mailboxes.keys(): -                if others != name and others.startswith(name): -                    raise imap4.MailboxException, ( -                        "Hierarchically inferior mailboxes " -                        "exist and \\Noselect is set") + +        mbox = self.getMailbox(name) + +        if force is False: +            # See if this box is flagged \Noselect +            # XXX use mbox.flags instead? +            if r'\Noselect' in mbox.getFlags(): +                # Check for hierarchically inferior mailboxes with this one +                # as part of their root. +                for others in self.mailboxes: +                    if others != name and others.startswith(name): +                        raise imap4.MailboxException, ( +                            "Hierarchically inferior mailboxes " +                            "exist and \\Noselect is set")          mbox.destroy() -        # iff there are no hierarchically inferior names, we will +        # XXX FIXME --- not honoring the inferior names... + +        # if there are no hierarchically inferior names, we will          # delete it from our ken. -        if self._inferiorNames(name) > 1: -            del self.mailboxes[name] +        #if self._inferiorNames(name) > 1: +            # ??! -- can this be rite? +            #self._index.removeMailbox(name)      def rename(self, oldname, newname): +        """ +        Renames a mailbox +        @param oldname: old name of the mailbox +        @param newname: new name of the mailbox +        """          oldname = oldname.upper()          newname = newname.upper() +          if oldname not in self.mailboxes:              raise imap4.NoSuchMailbox, oldname @@ -304,34 +336,102 @@ class SoledadBackedAccount(object):                  raise imap4.MailboxCollision, new          for (old, new) in inferiors: -            self.mailboxes[new] = self.mailboxes[old] -            del self.mailboxes[old] +            mbox = self._get_mailbox_by_name(old) +            mbox.content['mbox'] = new +            self._db.put_doc(mbox) + +        # XXX ---- FIXME!!!! ------------------------------------ +        # until here we just renamed the index... +        # We have to rename also the occurrence of this +        # mailbox on ALL the messages that are contained in it!!! +        # ... we maybe could use a reference to the doc_id +        # in each msg, instead of the "mbox" field in msgs +        # -------------------------------------------------------      def _inferiorNames(self, name): +        """ +        Return hierarchically inferior mailboxes +        @param name: the mailbox +        @rtype: list +        """ +        # XXX use wildcard query instead          inferiors = [] -        for infname in self.mailboxes.keys(): +        for infname in self.mailboxes:              if infname.startswith(name):                  inferiors.append(infname)          return inferiors      def isSubscribed(self, name): -        return name.upper() in self.subscriptions +        """ +        Returns True if user is subscribed to this mailbox. + +        @param name: the mailbox to be checked. +        @rtype: bool +        """ +        mbox = self._get_mailbox_by_name(name) +        return mbox.content.get('subscribed', False) + +    def _set_subscription(self, name, value): +        """ +        Sets the subscription value for a given mailbox + +        @param name: the mailbox +        @type name: C{str} + +        @param value: the boolean value +        @type value: C{bool} +        """ +        # maybe we should store subscriptions in another +        # document... +        if not name in self.mailboxes: +            print "not this mbox" +            self.addMailbox(name) +        mbox = self._get_mailbox_by_name(name) + +        if mbox: +            mbox.content['subscribed'] = value +            self._db.put_doc(mbox)      def subscribe(self, name): +        """ +        Subscribe to this mailbox + +        @param name: the mailbox +        @type name: C{str} +        """          name = name.upper()          if name not in self.subscriptions: -            self._index.addSubscription(name) +            self._set_subscription(name, True)      def unsubscribe(self, name): +        """ +        Unsubscribe from this mailbox + +        @param name: the mailbox +        @type name: C{str} +        """          name = name.upper()          if name not in self.subscriptions:              raise imap4.MailboxException, "Not currently subscribed to " + name -        self._index.removeSubscription(name) +        self._set_subscription(name, False)      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 +        @param wildcard: mailbox name with possible wildcards +        """ +        # XXX use wildcard in index query          ref = self._inferiorNames(ref.upper())          wildcard = imap4.wildcardToRegexp(wildcard, '/') -        return [(i, self.mailboxes[i]) for i in ref if wildcard.match(i)] +        return [(i, self.getMailbox(i)) for i in ref if wildcard.match(i)]      ##      ## INamespacePresenter @@ -346,176 +446,615 @@ class SoledadBackedAccount(object):      def getOtherNamespaces(self):          return None +    # extra, for convenience + +    def deleteAllMessages(self, iknowhatiamdoing=False): +        """ +        Deletes all messages from all mailboxes. +        Danger! high voltage! + +        @param iknowhatiamdoing: confirmation parameter, needs to be True +            to proceed. +        """ +        if iknowhatiamdoing is True: +            for mbox in self.mailboxes: +                self.delete(mbox, force=True) + +  #######################################  # Soledad Message, MessageCollection  # and Mailbox  ####################################### -FLAGS_INDEX = 'flags' -SEEN_INDEX = 'seen' -INDEXES = {FLAGS_INDEX: ['flags'], -           SEEN_INDEX: ['bool(seen)'], -} - - -class Message(u1db.Document): -    """A rfc822 message item.""" -    # XXX TODO use email module -    def _get_subject(self): -        """Get the message title.""" -        return self.content.get('subject') +class LeapMessage(object): -    def _set_subject(self, subject): -        """Set the message title.""" -        self.content['subject'] = subject +    implements(imap4.IMessage, imap4.IMessageFile) -    subject = property(_get_subject, _set_subject, -                       doc="Subject of the message.") +    def __init__(self, doc): +        """ +        Initializes a LeapMessage. -    def _get_seen(self): -        """Get the seen status of the message.""" -        return self.content.get('seen', False) +        @type doc: C{LeapDocument} +        @param doc: A LeapDocument containing the internal +        representation of the message +        """ +        self._doc = doc -    def _set_seen(self, value): -        """Set the seen status.""" -        self.content['seen'] = value +    def getUID(self): +        """ +        Retrieve the unique identifier associated with this message -    seen = property(_get_seen, _set_seen, doc="Seen flag.") +        @rtype: C{int} +        """ +        if not self._doc: +            log.msg('BUG!!! ---- message has no doc!') +            return +        return self._doc.content['uid'] -    def _get_flags(self): -        """Get flags associated with the message.""" -        return self.content.setdefault('flags', []) +    def getFlags(self): +        """ +        Retrieve the flags associated with this message + +        @rtype: C{iterable} +        @return: The flags, represented as strings +        """ +        if self._doc is None: +            return [] +        flags = self._doc.content.get('flags', None) +        if flags: +            flags = map(str, flags) +        return flags + +    # setFlags, addFlags, removeFlags are not in the interface spec +    # but we use them with store command. + +    def setFlags(self, flags): +        """ +        Sets the flags for this message + +        Returns a LeapDocument that needs to be updated by the caller. + +        @type flags: sequence of C{str} +        @rtype: LeapDocument +        """ +        log.msg('setting flags') +        doc = self._doc +        doc.content['flags'] = flags +        doc.content['seen'] = "\\Seen" in flags +        doc.content['recent'] = "\\Recent" in flags +        return self._doc + +    def addFlags(self, flags): +        """ +        Adds flags to this message + +        Returns a document that needs to be updated by the caller. + +        @type flags: sequence of C{str} +        @rtype: LeapDocument +        """ +        oldflags = self.getFlags() +        return self.setFlags(list(set(flags + oldflags))) + +    def removeFlags(self, flags): +        """ +        Remove flags from this message. + +        Returns a document that needs to be updated by the caller. + +        @type flags: sequence of C{str} +        @rtype: LeapDocument +        """ +        oldflags = self.getFlags() +        return self.setFlags(list(set(oldflags) - set(flags))) + +    def getInternalDate(self): +        """ +        Retrieve the date internally associated with this message + +        @rtype: C{str} +        @retur: An RFC822-formatted date string. +        """ +        return str(self._doc.content.get('date', '')) + +    # +    # IMessageFile +    # -    def _set_flags(self, flags): -        """Set flags associated with the message.""" -        self.content['flags'] = list(set(flags)) +    """ +    Optional message interface for representing messages as files. -    flags = property(_get_flags, _set_flags, doc="Message flags.") +    If provided by message objects, this interface will be used instead +    the more complex MIME-based interface. +    """ -EMPTY_MSG = { -    "subject": "", -    "seen": False, -    "flags": [], -    "mailbox": "", -} -get_empty_msg = lambda: copy.deepcopy(EMPTY_MSG) +    def open(self): +        """ +        Return an file-like object opened for reading. + +        Reading from the returned file will return all the bytes +        of which this message consists. +        """ +        fd = cStringIO.StringIO() +        fd.write(str(self._doc.content.get('raw', ''))) +        fd.seek(0) +        return fd + +    # +    # IMessagePart +    # + +    # XXX should implement the rest of IMessagePart interface: +    # (and do not use the open above) + +    def getBodyFile(self): +        """ +        Retrieve a file object containing only the body of this message. + +        @rtype: C{StringIO} +        """ +        fd = StringIO.StringIO() +        fd.write(str(self._doc.content.get('raw', ''))) +        # SHOULD use a separate BODY FIELD ... +        fd.seek(0) +        return fd + +    def getSize(self): +        """ +        Return the total size, in octets, of this message + +        @rtype: C{int} +        """ +        return self.getBodyFile().len + +    def _get_headers(self): +        """ +        Return the headers dict stored in this message document +        """ +        return self._doc.content['headers'] + +    def getHeaders(self, negate, *names): +        """ +        Retrieve a group of message headers. + +        @type names: C{tuple} of C{str} +        @param names: The names of the headers to retrieve or omit. + +        @type negate: C{bool} +        @param negate: If True, indicates that the headers listed in C{names} +        should be omitted from the return value, rather than included. + +        @rtype: C{dict} +        @return: A mapping of header field names to header field values +        """ +        headers = self._get_headers() +        if negate: +            cond = lambda key: key.upper() not in names +        else: +            cond = lambda key: key.upper() in names +        return dict( +            [map(str, (key, val)) for key, val in headers.items() +             if cond(key)]) + +    # --- no multipart for now + +    def isMultipart(self): +        return False + +    def getSubPart(part): +        return None  class MessageCollection(object):      """ -    A collection of messages +    A collection of messages, surprisingly. + +    It is tied to a selected mailbox name that is passed to constructor. +    Implements a filter query over the messages contained in a soledad +    database.      """ +    # XXX this should be able to produce a MessageSet methinks + +    EMPTY_MSG = { +        "type": "msg", +        "uid": 1, +        "mbox": "inbox", +        "subject": "", +        "date": "", +        "seen": False, +        "recent": True, +        "flags": [], +        "headers": {}, +        "raw": "", +    }      def __init__(self, mbox=None, db=None): -        assert mbox +        """ +        Constructor for MessageCollection. + +        @param mbox: the name of the mailbox. It is the name +                     with which we filter the query over the +                     messages database +        @type mbox: C{str} + +        @param db: SQLCipher database (contained in soledad) +        @type db: SQLCipher instance +        """ +        leap_assert(mbox, "Need a mailbox name to initialize") +        leap_assert(mbox.strip() != "", "mbox cannot be blank space") +        leap_assert(isinstance(mbox, (str, unicode)), +                    "mbox needs to be a string") +        leap_assert(db, "Need a db instance to initialize") +        leap_assert(isinstance(db, SQLCipherDatabase), +                    "db must be an instance of SQLCipherDatabase") + +        # okay, all in order, keep going... + +        self.mbox = mbox.upper()          self.db = db -        self.initialize_db() +        self._parser = Parser() -    def initialize_db(self): -        """Initialize the database.""" -        # Ask the database for currently existing indexes. -        db_indexes = dict(self.db.list_indexes()) -        # Loop through the indexes we expect to find. -        for name, expression in INDEXES.items(): -            print 'name is', name -            if name not in db_indexes: -                # The index does not yet exist. -                print 'creating index' -                self.db.create_index(name, *expression) -                continue +    def _get_empty_msg(self): +        """ +        Returns an empty message. -            if expression == db_indexes[name]: -                print 'expression up to date' -                # The index exists and is up to date. -                continue -            # The index exists but the definition is not what expected, so we -            # delete it and add the proper index expression. -            print 'deleting index' -            self.db.delete_index(name) -            self.db.create_index(name, *expression) +        @rtype: dict +        """ +        return copy.deepcopy(self.EMPTY_MSG) + +    def add_msg(self, raw, subject=None, flags=None, date=None, uid=1): +        """ +        Creates a new message document. + +        @param raw: the raw message +        @type raw: C{str} + +        @param subject: subject of the message. +        @type subject: C{str} -    def add_msg(self, subject=None, flags=None): -        """Create a new message document.""" +        @param flags: flags +        @type flags: C{list} + +        @param date: the received date for the message +        @type date: C{str} + +        @param uid: the message uid for this mailbox +        @type uid: C{int} +        """          if flags is None: -            flags = [] -        content = get_empty_msg() -        if subject or flags: -            content['subject'] = subject -            content['flags'] = flags -        # Store the document in the database. Since we did not set a document -        # id, the database will store it as a new document, and generate -        # a valid id. +            flags = tuple() +        leap_assert_type(flags, tuple) + +        def stringify(o): +            if isinstance(o, (cStringIO.OutputType, StringIO.StringIO)): +                return o.getvalue() +            else: +                return o + +        content = self._get_empty_msg() +        content['mbox'] = self.mbox + +        if flags: +            content['flags'] = map(stringify, flags) +            content['seen'] = "\\Seen" in flags + +        def _get_parser_fun(o): +            if isinstance(o, (cStringIO.OutputType, StringIO.StringIO)): +                return self._parser.parse +            if isinstance(o, (str, unicode)): +                return self._parser.parsestr + +        msg = _get_parser_fun(raw)(raw, True) +        headers = dict(msg) + +        # XXX get lower case for keys? +        content['headers'] = headers +        content['subject'] = headers['Subject'] +        content['raw'] = stringify(raw) + +        if not date: +            content['date'] = headers['Date'] + +        # ...should get a sanity check here. +        content['uid'] = uid +          return self.db.create_doc(content) +    def remove(self, msg): +        """ +        Removes a message. + +        @param msg: a u1db doc containing the message +        """ +        self.db.delete_doc(msg) + +    # getters + +    def get_by_uid(self, uid): +        """ +        Retrieves a message document by UID +        """ +        docs = self.db.get_from_index( +            SoledadBackedAccount.TYPE_MBOX_UID_IDX, 'msg', self.mbox, str(uid)) +        return docs[0] if docs else None + +    def get_msg_by_uid(self, uid): +        """ +        Retrieves a LeapMessage by UID +        """ +        doc = self.get_by_uid(uid) +        if doc: +            return LeapMessage(doc) +      def get_all(self): -        """Get all messages""" -        return self.db.get_from_index(SEEN_INDEX, "*") +        """ +        Get all messages for the selected mailbox +        Returns a list of u1db documents. +        If you want acess to the content, use __iter__ instead + +        @rtype: list +        """ +        # XXX this should return LeapMessage instances +        return self.db.get_from_index( +            SoledadBackedAccount.TYPE_MBOX_IDX, 'msg', self.mbox) + +    def unseen_iter(self): +        """ +        Get an iterator for the message docs with no `seen` flag + +        @rtype: C{iterable} +        """ +        return (doc for doc in +                self.db.get_from_index( +                    SoledadBackedAccount.TYPE_MBOX_RECT_IDX, +                    'msg', self.mbox, '1'))      def get_unseen(self): -        """Get only unseen messages""" -        return self.db.get_from_index(SEEN_INDEX, "0") +        """ +        Get all messages with the `Unseen` flag + +        @rtype: C{list} +        @returns: a list of LeapMessages +        """ +        return [LeapMessage(doc) for doc in self.unseen_iter()] + +    def recent_iter(self): +        """ +        Get an iterator for the message docs with recent flag. + +        @rtype: C{iterable} +        """ +        return (doc for doc in +                self.db.get_from_index( +                    SoledadBackedAccount.TYPE_MBOX_RECT_IDX, +                    'msg', self.mbox, '1')) + +    def get_recent(self): +        """ +        Get all messages with the `Recent` flag. + +        @type: C{list} +        @returns: a list of LeapMessages +        """ +        return [LeapMessage(doc) for doc in self.recent_iter()]      def count(self): +        """ +        Return the count of messages for this mailbox. + +        @rtype: C{int} +        """          return len(self.get_all()) +    def __len__(self): +        """ +        Returns the number of messages on this mailbox -class SoledadMailbox: -    """ -    A Soledad-backed IMAP mailbox +        @rtype: C{int} +        """ +        return self.count() + +    def __iter__(self): +        """ +        Returns an iterator over all messages. + +        @rtype: C{iterable} +        @returns: iterator of dicts with content for all messages. +        """ +        return (m.content for m in self.get_all()) + +    def __getitem__(self, uid): +        """ +        Allows indexing as a list, with msg uid as the index. + +        @type key: C{int} +        @param key: an integer index +        """ +        try: +            return self.get_msg_by_uid(uid) +        except IndexError: +            return None + +    def __repr__(self): +        return u"<MessageCollection: mbox '%s' (%s)>" % ( +            self.mbox, self.count()) + +    # XXX should implement __eq__ also + + +class SoledadMailbox(object):      """ +    A Soledad-backed IMAP mailbox. +    Implements the high-level method needed for the Mailbox interfaces. +    The low-level database methods are contained in MessageCollection class, +    which we instantiate and make accessible in the `messages` attribute. +    """      implements(imap4.IMailboxInfo, imap4.IMailbox, imap4.ICloseableMailbox) -    flags = ('\\Seen', '\\Answered', '\\Flagged', -             '\\Deleted', '\\Draft', '\\Recent', 'List') - -    #messages = []      messages = None -    mUID = 0 -    rw = 1 -    closed = False +    _closed = False + +    INIT_FLAGS = ('\\Seen', '\\Answered', '\\Flagged', +                  '\\Deleted', '\\Draft', '\\Recent', 'List') +    DELETED_FLAG = '\\Deleted' +    flags = None + +    def __init__(self, mbox, soledad=None, rw=1): +        """ +        SoledadMailbox constructor +        Needs to get passed a name, plus a soledad instance and +        the soledad account index, where it stores the flags for this +        mailbox. + +        @param mbox: the mailbox name +        @type mbox: C{str} + +        @param soledad: a Soledad instance. +        @type soledad: C{Soledad} + +        @param rw: read-and-write flags +        @type rw: C{int} +        """ +        leap_assert(mbox, "Need a mailbox name to initialize") +        leap_assert(soledad, "Need a soledad instance to initialize") +        leap_assert(isinstance(soledad._db, SQLCipherDatabase), +                    "soledad._db must be an instance of SQLCipherDatabase") -    def __init__(self, mbox, soledad=None): -        # XXX sanity check: -        #soledad is not None and isinstance(SQLCipherDatabase, soldad._db) +        self.mbox = mbox +        self.rw = rw + +        self._soledad = soledad +        self._db = soledad._db + +        self.messages = MessageCollection( +            mbox=mbox, db=soledad._db) + +        if not self.getFlags(): +            self.setFlags(self.INIT_FLAGS) + +        # XXX what is/was this used for? -------- +        # ---> mail/imap4.py +1155, +        #      _cbSelectWork makes use of this +        # probably should implement hooks here +        # using leap.common.events          self.listeners = []          self.addListener = self.listeners.append          self.removeListener = self.listeners.remove -        self._soledad = soledad -        if soledad: -            self.messages = MessageCollection( -                mbox=mbox, db=soledad._db) +        #------------------------------------------ + +    def _get_mbox(self): +        """Returns mailbox document""" +        return self._db.get_from_index( +            SoledadBackedAccount.TYPE_MBOX_IDX, 'mbox', self.mbox)[0]      def getFlags(self): -        return self.messages.db.get_index_keys(FLAGS_INDEX) +        """ +        Returns the possible flags of this mailbox +        @rtype: tuple +        """ +        mbox = self._get_mbox() +        flags = mbox.content.get('flags', []) +        return map(str, flags) + +    def setFlags(self, flags): +        """ +        Sets flags for this mailbox +        @param flags: a tuple with the flags +        """ +        leap_assert(isinstance(flags, tuple), +                    "flags expected to be a tuple") +        mbox = self._get_mbox() +        mbox.content['flags'] = map(str, flags) +        self._db.put_doc(mbox) + +    # XXX SHOULD BETTER IMPLEMENT ADD_FLAG, REMOVE_FLAG. + +    def _get_closed(self): +        mbox = self._get_mbox() +        return mbox.content.get('closed', False) + +    def _set_closed(self, closed): +        leap_assert(isinstance(closed, bool), "closed needs to be boolean") +        mbox = self._get_mbox() +        mbox.content['closed'] = closed +        self._db.put_doc(mbox) + +    closed = property( +        _get_closed, _set_closed, doc="Closed attribute.")      def getUIDValidity(self): -        return 42 +        """ +        Return the unique validity identifier for this mailbox. + +        @rtype: C{int} +        """ +        mbox = self._get_mbox() +        return mbox.content.get('created', 1) + +    def getUID(self, message): +        """ +        Return the UID of a message in the mailbox + +        @rtype: C{int} +        """ +        msg = self.messages.get_msg_by_uid(message) +        return msg.getUID() + +    def getRecentCount(self): +        """ +        Returns the number of messages with the 'Recent' flag + +        @rtype: C{int} +        """ +        return len(self.messages.get_recent())      def getUIDNext(self): +        """ +        Return the likely UID for the next message added to this +        mailbox + +        @rtype: C{int} +        """ +        # XXX reimplement with proper index          return self.messages.count() + 1      def getMessageCount(self): +        """ +        Returns the total count of messages in this mailbox +        """          return self.messages.count()      def getUnseenCount(self): +        """ +        Returns the total count of unseen messages in this mailbox +        """          return len(self.messages.get_unseen()) -    def getRecentCount(self): -        # XXX -        return 3 -      def isWriteable(self): +        """ +        Get the read/write status of the mailbox +        @rtype: C{int} +        """          return self.rw -    def destroy(self): -        pass -      def getHierarchicalDelimiter(self): +        """ +        Returns the character used to delimite hierarchies in mailboxes + +        @rtype: C{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 = {}          if 'MESSAGES' in names:              r['MESSAGES'] = self.getMessageCount() @@ -530,29 +1069,159 @@ class SoledadMailbox:          return defer.succeed(r)      def addMessage(self, message, flags, date=None): -        # self.messages.add_msg((msg, flags, date, self.mUID)) -        #self.messages.append((message, flags, date, self.mUID)) -        # XXX CHANGE-ME -        self.messages.add_msg(subject=message, flags=flags, date=date) -        self.mUID += 1 +        """ +        Adds a message to this mailbox +        @param message: the raw message +        @flags: flag list +        @date: timestamp +        """ +        # XXX we should treat the message as an IMessage from here +        uid_next = self.getUIDNext() +        flags = tuple(str(flag) for flag in flags) + +        self.messages.add_msg(message, flags=flags, date=date, +                              uid=uid_next)          return defer.succeed(None) -    def deleteAllDocs(self): -        """deletes all docs""" -        docs = self.messages.db.get_all_docs()[1] -        for doc in docs: -            self.messages.db.delete_doc(doc) +    # 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. +        """ +        self.setFlags(('\\Noselect',)) +        self.deleteAllDocs() + +        # XXX removing the mailbox in situ for now, +        # we should postpone the removal +        self._db.delete_doc(self._get_mbox())      def expunge(self): -        """deletes all messages flagged \\Deleted""" -        # XXX FIXME! +        """ +        Remove all messages flagged \\Deleted +        """ +        if not self.isWriteable(): +            raise imap4.ReadOnlyMailbox +          delete = [] -        for i in self.messages: -            if '\\Deleted' in i[1]: -                delete.append(i) -        for i in delete: -            self.messages.remove(i) -        return [i[3] for i in delete] +        deleted = [] +        for m in self.messages.get_all(): +            if self.DELETED_FLAG in m.content['flags']: +                delete.append(m) +        for m in delete: +            deleted.append(m.content) +            self.messages.remove(m) + +        # XXX should return the UIDs of the deleted messages +        # more generically +        return [x for x in range(len(deleted))] + +    def fetch(self, messages, 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. + +        @type messages: C{MessageSet} +        @param messages: IDs of the messages to retrieve information about + +        @type uid: C{bool} +        @param uid: If true, the IDs are UIDs. They are message sequence IDs +        otherwise. + +        @rtype: A tuple of two-tuples of message sequence numbers and +        C{LeapMessage} +        """ +        # XXX implement sequence numbers (uid = 0) +        result = [] + +        if not messages.last: +            messages.last = self.messages.count() + +        for msg_id in messages: +            msg = self.messages.get_msg_by_uid(msg_id) +            if msg: +                result.append((msg_id, msg)) +        return tuple(result) + +    def store(self, messages, flags, mode, uid): +        """ +        Sets the flags of one or more messages. + +        @type messages: A MessageSet object with the list of messages requested +        @param messages: The identifiers of the messages to set the flags + +        @type flags: sequence of {str} +        @param flags: The flags to set, unset, or  add. + +        @type mode: -1, 0, or 1 +        @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 uid: C{bool} +        @param uid: If true, the IDs specified in the query are UIDs; +        otherwise they are message sequence IDs. + +        @rtype: C{dict} +        @return: A C{dict} mapping message sequence numbers to sequences of +        C{str} +        representing the flags set on the message after this operation has +        been performed, or a C{Deferred} whose callback will be invoked with +        such a dict + +        @raise ReadOnlyMailbox: Raised if this mailbox is not open for +        read-write. +        """ +        # XXX implement also sequence (uid = 0) +        if not self.isWriteable(): +            raise imap4.ReadOnlyMailbox + +        if not messages.last: +            messages.last = self.messages.count() + +        result = {} +        for msg_id in messages: +            msg = self.messages.get_msg_by_uid(msg_id) +            if mode == 1: +                self._update(msg.addFlags(flags)) +            elif mode == -1: +                self._update(msg.removeFlags(flags)) +            elif mode == 0: +                self._update(msg.setFlags(flags)) +            result[msg_id] = msg.getFlags() + +        return result      def close(self): +        """ +        Expunge and mark as closed +        """ +        self.expunge()          self.closed = True + +    # convenience fun + +    def deleteAllDocs(self): +        """ +        Deletes all docs in this mailbox +        """ +        docs = self.messages.get_all() +        for doc in docs: +            self.messages.db.delete_doc(doc) + +    def _update(self, doc): +        """ +        Updates document in u1db database +        """ +        #log.msg('updating doc... %s ' % doc) +        self._db.put_doc(doc) + +    def __repr__(self): +        return u"<SoledadMailbox: mbox '%s' (%s)>" % ( +            self.mbox, self.messages.count()) diff --git a/mail/src/leap/mail/imap/service/README.rst b/mail/src/leap/mail/imap/service/README.rst new file mode 100644 index 0000000..2cca9b3 --- /dev/null +++ b/mail/src/leap/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/mail/src/leap/mail/imap/service/imap-server.tac b/mail/src/leap/mail/imap/service/imap-server.tac new file mode 100644 index 0000000..e491e06 --- /dev/null +++ b/mail/src/leap/mail/imap/service/imap-server.tac @@ -0,0 +1,230 @@ +import ConfigParser +import datetime +import os +from functools import partial + +from xdg import BaseDirectory + +from twisted.application import internet, service +from twisted.internet.protocol import ServerFactory +from twisted.mail import imap4 +from twisted.python import log + +from leap.common.check import leap_assert +from leap.mail.imap.server import SoledadBackedAccount +from leap.mail.imap.fetch import LeapIncomingMail +from leap.soledad import Soledad +#from leap.soledad import SoledadCrypto + +# Some constants +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# The port in which imap service will run +IMAP_PORT = 9930 + +# The period between succesive checks of the incoming mail +# queue (in seconds) +INCOMING_CHECK_PERIOD = 10 +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +class LeapIMAPServer(imap4.IMAP4Server): +    """ +    An IMAP4 Server with mailboxes backed by soledad +    """ +    def __init__(self, *args, **kwargs): +        # pop extraneous arguments +        soledad = kwargs.pop('soledad', None) +        user = kwargs.pop('user', None) +        gpg = kwargs.pop('gpg', None) +        leap_assert(soledad, "need a soledad instance") +        leap_assert(user, "need a user in the initialization") + +        # initialize imap server! +        imap4.IMAP4Server.__init__(self, *args, **kwargs) + +        # we should initialize the account here, +        # but we move it to the factory so we can +        # populate the test account properly (and only once +        # per session) + +        # theAccount = SoledadBackedAccount( +        #     user, soledad=soledad) + +        # --------------------------------- +        # XXX pre-populate acct for tests!! +        # populate_test_account(theAccount) +        # --------------------------------- +        #self.theAccount = theAccount + +    def lineReceived(self, line): +        log.msg('rcv: %s' % line) +        imap4.IMAP4Server.lineReceived(self, line) + +    def authenticateLogin(self, username, password): +        # all is allowed so far. use realm instead +        return imap4.IAccount, self.theAccount, lambda: None + + +class IMAPAuthRealm(object): +    """ +    dummy authentication realm +    """ +    theAccount = None + +    def requestAvatar(self, avatarId, mind, *interfaces): +        return imap4.IAccount, self.theAccount, lambda: None + + +class LeapIMAPFactory(ServerFactory): +    """ +    Factory for a IMAP4 server with soledad remote sync and gpg-decryption +    capabilities. +    """ + +    def __init__(self, user, soledad, gpg=None): +        self._user = user +        self._soledad = soledad +        self._gpg = gpg + +        theAccount = SoledadBackedAccount( +            user, soledad=soledad) + +        # --------------------------------- +        # XXX pre-populate acct for tests!! +        # populate_test_account(theAccount) +        # --------------------------------- +        self.theAccount = theAccount + +    def buildProtocol(self, addr): +        "Return a protocol suitable for the job." +        imapProtocol = LeapIMAPServer( +            user=self._user, +            soledad=self._soledad, +            gpg=self._gpg) +        imapProtocol.theAccount = self.theAccount +        imapProtocol.factory = self +        return imapProtocol + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# +# Let's rock... +# +# XXX initialize gpg + +#from leap.mail.imap.tests import PUBLIC_KEY +#from leap.mail.imap.tests import PRIVATE_KEY +#from leap.soledad.util import GPGWrapper + + +def initialize_mailbox_soledad(user_uuid, soledad_pass, server_url, +                       	       server_pemfile, token): +    """ +    Initializes soledad by hand + +    :param user_uuid: +    :param soledad_pass: +    :param server_url: +    :param server_pemfile: +    :param token: + +    :rtype: Soledad instance +    """ +    #XXX do we need a separate instance for the mailbox db? + +    base_config = BaseDirectory.xdg_config_home +    secret_path = os.path.join( +        base_config, "leap", "soledad", "%s.secret" % user_uuid) +    soledad_path = os.path.join( +        base_config, "leap", "soledad", "%s-mailbox.db" % user_uuid) + + +    _soledad = Soledad( +        user_uuid, +	soledad_pass, +        secret_path, +        soledad_path, +	server_url, +        server_pemfile, +        token, +        bootstrap=True) +    #_soledad._init_dirs() +    #_soledad._crypto = SoledadCrypto(_soledad) +    #_soledad._shared_db = None +    #_soledad._init_keys() +    #_soledad._init_db() + +    return _soledad + +''' +mail_sample = open('rfc822.message').read() +def populate_test_account(acct): +    """ +    Populates inbox for testing purposes +    """ +    print "populating test account!" +    inbox = acct.getMailbox('inbox') +    inbox.addMessage(mail_sample, ("\\Foo", "\\Recent",), date="Right now2") +''' + +def incoming_check(fetcher): +    """ +    Check incoming queue. To be called periodically. +    """ +    #log.msg("checking incoming queue...") +    fetcher.fetch() + + +####################################################################### +# XXX STUBBED! We need to get this in the instantiation from the client + +config = ConfigParser.ConfigParser() +config.read([os.path.expanduser('~/.config/leap/mail/mail.conf')]) + +userID = config.get('mail', 'address') +privkey = open(os.path.expanduser('~/.config/leap/mail/privkey')).read() +nickserver_url = "" + +d = {} + +for key in ('uid', 'passphrase', 'server', 'pemfile', 'token'): +    d[key] = config.get('mail', key) + +soledad = initialize_mailbox_soledad( +    d['uid'], +    d['passphrase'], +    d['server'], +    d['pemfile'], +    d['token']) +gpg = None + +# import the private key ---- should sync it from remote! +from leap.common.keymanager.openpgp import OpenPGPScheme +opgp = OpenPGPScheme(soledad) +opgp.put_ascii_key(privkey) + +from leap.common.keymanager import KeyManager +keym = KeyManager(userID, nickserver_url, soledad, d['token']) + +#import ipdb; ipdb.set_trace() + + +factory = LeapIMAPFactory(userID, soledad, gpg) + +application = service.Application("LEAP IMAP4 Local Service") +imapService = internet.TCPServer(IMAP_PORT, factory) +imapService.setServiceParent(application) + +fetcher = LeapIncomingMail( +    keym, +    d['uid'], +    d['passphrase'], +    d['server'], +    d['pemfile'], +    d['token'], +    factory.theAccount) + + +incoming_check_for_acct = partial(incoming_check, fetcher) +internet.TimerService( +    INCOMING_CHECK_PERIOD, +    incoming_check_for_acct).setServiceParent(application) diff --git a/mail/src/leap/mail/imap/service/notes.txt b/mail/src/leap/mail/imap/service/notes.txt new file mode 100644 index 0000000..623e122 --- /dev/null +++ b/mail/src/leap/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/mail/src/leap/mail/imap/service/rfc822.message b/mail/src/leap/mail/imap/service/rfc822.message new file mode 100644 index 0000000..ee97ab9 --- /dev/null +++ b/mail/src/leap/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/mail/src/leap/mail/imap/tests/__init__.py b/mail/src/leap/mail/imap/tests/__init__.py index 9a4c663..315d649 100644 --- a/mail/src/leap/mail/imap/tests/__init__.py +++ b/mail/src/leap/mail/imap/tests/__init__.py @@ -48,18 +48,19 @@ class BaseSoledadIMAPTest(BaseLeapTest):                                document_factory=LeapDocument)          self._db2 = u1db.open(self.db2_file, create=True,                                document_factory=LeapDocument) +          # initialize soledad by hand so we can control keys          self._soledad = Soledad(self.email, gnupg_home=self.gnupg_home, -                                initialize=False, +                                bootstrap=False,                                  prefix=self.tempdir)          self._soledad._init_dirs()          self._soledad._gpg = GPGWrapper(gnupghome=self.gnupg_home) -        self._soledad._gpg.import_keys(PUBLIC_KEY) -        self._soledad._gpg.import_keys(PRIVATE_KEY) -        self._soledad._load_openpgp_keypair() -        if not self._soledad._has_secret(): -            self._soledad._gen_secret() -        self._soledad._load_secret() + +        if not self._soledad._has_privkey(): +            self._soledad._set_privkey(PRIVATE_KEY) +        if not self._soledad._has_symkey(): +            self._soledad._gen_symkey() +        self._soledad._load_symkey()          self._soledad._init_db()      def tearDown(self): diff --git a/mail/src/leap/mail/imap/tests/test_imap.py b/mail/src/leap/mail/imap/tests/test_imap.py index 6792e4b..6b6c24e 100644 --- a/mail/src/leap/mail/imap/tests/test_imap.py +++ b/mail/src/leap/mail/imap/tests/test_imap.py @@ -1,37 +1,54 @@ -#-*- encoding: utf-8 -*- +# -*- 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/>.  """ -leap/email/imap/tests/test_imap.py -----------------------------------  Test case for leap.email.imap.server +TestCases taken from twisted tests and modified to make them work +against SoledadBackedAccount.  @authors: Kali Kaneko, <kali@leap.se> +XXX add authors from the original twisted tests. +  @license: GPLv3, see included LICENSE file -@copyright: © 2013 Kali Kaneko, see COPYLEFT file  """ +# XXX review license of the original tests!!!  try:      from cStringIO import StringIO  except ImportError:      from StringIO import StringIO -import codecs -import locale +#import codecs +#import locale  import os  import types  import tempfile  import shutil -from zope.interface import implements +#from zope.interface import implements -from twisted.mail.imap4 import MessageSet +#from twisted.mail.imap4 import MessageSet  from twisted.mail import imap4  from twisted.protocols import loopback  from twisted.internet import defer -from twisted.internet import error -from twisted.internet import reactor -from twisted.internet import interfaces -from twisted.internet.task import Clock +#from twisted.internet import error +#from twisted.internet import reactor +#from twisted.internet import interfaces +#from twisted.internet.task import Clock  from twisted.trial import unittest  from twisted.python import util, log  from twisted.python import failure @@ -42,19 +59,20 @@ import twisted.cred.checkers  import twisted.cred.credentials  import twisted.cred.portal -from twisted.test.proto_helpers import StringTransport, StringTransportWithDisconnection +#from twisted.test.proto_helpers import StringTransport, StringTransportWithDisconnection -import u1db +#import u1db  from leap.common.testing.basetest import BaseLeapTest  from leap.mail.imap.server import SoledadMailbox -from leap.mail.imap.tests import PUBLIC_KEY -from leap.mail.imap.tests import PRIVATE_KEY +from leap.mail.imap.server import SoledadBackedAccount +from leap.mail.imap.server import MessageCollection +#from leap.mail.imap.tests import PUBLIC_KEY +#from leap.mail.imap.tests import PRIVATE_KEY  from leap.soledad import Soledad -from leap.soledad.util import GPGWrapper -from leap.soledad.backends.leap_backend import LeapDocument +from leap.soledad import SoledadCrypto  def strip(f): @@ -74,57 +92,61 @@ def sortNest(l):  def initialize_soledad(email, gnupg_home, tempdir):      """ -    initializes soledad by hand +    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      """ -    _soledad = Soledad(email, gnupg_home=gnupg_home, -                            initialize=False, -                            prefix=tempdir) + +    uuid = "foobar-uuid" +    passphrase = "verysecretpassphrase" +    secret_path = os.path.join(tempdir, "secret.gpg") +    local_db_path = os.path.join(tempdir, "soledad.u1db") +    server_url = "http://provider" +    cert_file = "" + +    _soledad = Soledad( +        uuid,  # user's uuid, obtained through signal events +        passphrase,  # how to get this? +        secret_path,  # how to get this? +        local_db_path,  # how to get this? +        server_url,  # can be None for now +        cert_file, +        bootstrap=False)      _soledad._init_dirs() -    _soledad._gpg = GPGWrapper(gnupghome=gnupg_home) -    _soledad._gpg.import_keys(PUBLIC_KEY) -    _soledad._gpg.import_keys(PRIVATE_KEY) -    _soledad._load_openpgp_keypair() -    if not _soledad._has_secret(): -        _soledad._gen_secret() -    _soledad._load_secret() +    _soledad._crypto = SoledadCrypto(_soledad) +    _soledad._shared_db = None +    _soledad._init_keys()      _soledad._init_db() +      return _soledad  ########################################## -# account, simpleserver +# Simple LEAP IMAP4 Server for testing  ########################################## +class SimpleLEAPServer(imap4.IMAP4Server): +    """ +    A Simple IMAP4 Server with mailboxes backed by Soledad. -class SoledadBackedAccount(imap4.MemoryAccount): -    #mailboxFactory = SimpleMailbox -    mailboxFactory = SoledadMailbox -    soledadInstance = None - -    # XXX should reimplement IAccount -> SoledadAccount -    # and receive the soledad instance on the constructor. -    # SoledadMailbox should allow to filter by mailbox name -    # _soledad db should include mailbox field -    # and a document with "INDEX" info (mailboxes / subscriptions) - -    def _emptyMailbox(self, name, id): -        return self.mailboxFactory(self.soledadInstance) - -    def select(self, name, rw=1): -        # XXX rethink this. -        # Need to be classmethods... -        mbox = imap4.MemoryAccount.select(self, name) -        if mbox is not None: -            mbox.rw = rw -        return mbox +    This should be pretty close to the real LeapIMAP4Server that we +    will be instantiating as a service, minus the authentication bits. +    """ +    def __init__(self, *args, **kw): +        soledad = kw.pop('soledad', None) -class SimpleLEAPServer(imap4.IMAP4Server): -    def __init__(self, *args, **kw):          imap4.IMAP4Server.__init__(self, *args, **kw)          realm = TestRealm() -        realm.theAccount = SoledadBackedAccount('testuser') -        # XXX soledadInstance here? + +        # XXX Why I AM PASSING THE ACCOUNT TO +        # REALM? I AM NOT USING  THAT NOW, AM I??? +        realm.theAccount = SoledadBackedAccount( +            'testuser', +            soledad=soledad)          portal = cred.portal.Portal(realm)          c = cred.checkers.InMemoryUsernamePasswordDatabaseDontUse() @@ -150,17 +172,25 @@ class SimpleLEAPServer(imap4.IMAP4Server):  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 -###################### -# Test LEAP Server -###################### + +###################################### +# Simple IMAP4 Client for testing +######################################  class SimpleClient(imap4.IMAP4Client): +    """ +    A Simple IMAP4 Client to test our +    Soledad-LEAPServer +    """      def __init__(self, deferred, contextFactory=None):          imap4.IMAP4Client.__init__(self, contextFactory) @@ -184,12 +214,28 @@ class SimpleClient(imap4.IMAP4Client):  class IMAP4HelperMixin(BaseLeapTest): +    """ +    MixIn containing several utilities to be shared across +    different TestCases +    """      serverCTX = None      clientCTX = None      @classmethod      def setUpClass(cls): +        """ +        TestCase initialization setup. +        Sets up a new environment. +        Initializes a SINGLE Soledad Instance that will be shared +        by all tests in this base class. +        This breaks orthogonality, avoiding us to use trial, so we should +        move away from this test design. But it's a quick way to get +        started without knowing / mocking the soledad api. + +        We do also some duplication with BaseLeapTest cause trial and nose +        seem not to deal well with deriving classmethods. +        """          cls.old_path = os.environ['PATH']          cls.old_home = os.environ['HOME']          cls.tempdir = tempfile.mkdtemp(prefix="leap_tests-") @@ -217,10 +263,20 @@ class IMAP4HelperMixin(BaseLeapTest):              cls.gnupg_home,              cls.tempdir) -        cls.sm = SoledadMailbox(soledad=cls._soledad) +        # now we're passing the mailbox name, so we +        # should get this into a partial or something. +        #cls.sm = SoledadMailbox("mailbox", soledad=cls._soledad) +        # XXX REFACTOR --- self.server (in setUp) is initializing +        # a SoledadBackedAccount      @classmethod      def tearDownClass(cls): +        """ +        TestCase teardown method. + +        Restores the old path and home environment variables. +        Removes the temporal dir created for tests. +        """          #cls._db1.close()          #cls._db2.close()          cls._soledad.close() @@ -232,33 +288,79 @@ class IMAP4HelperMixin(BaseLeapTest):          shutil.rmtree(cls.tempdir)      def setUp(self): +        """ +        Setup method for each test. + +        Initializes and run a LEAP IMAP4 Server, +        but passing the same Soledad instance (it's costly to initialize), +        so we have to be sure to restore state across tests. +        """          d = defer.Deferred() -        self.server = SimpleLEAPServer(contextFactory=self.serverCTX) +        self.server = SimpleLEAPServer( +            contextFactory=self.serverCTX, +            # XXX do we really need this?? +            soledad=self._soledad) +          self.client = SimpleClient(d, contextFactory=self.clientCTX)          self.connected = d -        theAccount = SoledadBackedAccount('testuser') -        theAccount.soledadInstance = self._soledad +        # XXX REVIEW-ME. +        # We're adding theAccount here to server +        # but it was also passed to initialization +        # as it was passed to realm. +        # I THINK we ONLY need to do it at one place now. -        # XXX used for something??? -        #theAccount.mboxType = SoledadMailbox +        theAccount = SoledadBackedAccount( +            'testuser', +            soledad=self._soledad)          SimpleLEAPServer.theAccount = theAccount +        # in case we get something from previous tests... +        for mb in self.server.theAccount.mailboxes: +            self.server.theAccount.delete(mb) +      def tearDown(self): +        """ +        tearDown method called after each test. + +        Deletes all documents in the Index, and deletes +        instances of server and client. +        """          self.delete_all_docs() +        acct = self.server.theAccount +        for mb in acct.mailboxes: +            acct.delete(mb) + +        # FIXME add again +        #for subs in acct.subscriptions: +            #acct.unsubscribe(subs) +          del self.server          del self.client          del self.connected      def populateMessages(self): -        self._soledad.messages.add_msg(subject="test1") -        self._soledad.messages.add_msg(subject="test2") -        self._soledad.messages.add_msg(subject="test3") +        """ +        Populates soledad instance with several simple messages +        """ +        # XXX we should encapsulate this thru SoledadBackedAccount +        # instead. + +        # XXX we also should put this in a mailbox! + +        self._soledad.messages.add_msg('', subject="test1") +        self._soledad.messages.add_msg('', subject="test2") +        self._soledad.messages.add_msg('', subject="test3")          # XXX should change Flags too -        self._soledad.messages.add_msg(subject="test4") +        self._soledad.messages.add_msg('', subject="test4")      def delete_all_docs(self): -        self.server.theAccount.messages.deleteAllDocs() +        """ +        Deletes all the docs in the testing instance of the +        SoledadBackedAccount. +        """ +        self.server.theAccount.deleteAllMessages( +            iknowhatiamdoing=True)      def _cbStopClient(self, ignore):          self.client.transport.loseConnection() @@ -272,206 +374,83 @@ class IMAP4HelperMixin(BaseLeapTest):          return loopback.loopbackAsync(self.server, self.client) -class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): - -    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.addCallback( -            strip(getCaps)).addErrback(self._ebGeneral) -        d = defer.gatherResults([self.loopback(), d1]) -        expected = {'IMAP4rev1': None, 'NAMESPACE': None, 'IDLE': None} - -        return d.addCallback(lambda _: self.assertEqual(expected, caps)) - -    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, 'AUTH': ['CRAM-MD5']} - -        return d.addCallback(lambda _: self.assertEqual(expCap, caps)) - -    def testLogout(self): -        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): -        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): -        def login(): -            d = self.client.login('testuser', 'password-test') -            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.account, SimpleLEAPServer.theAccount) -        self.assertEqual(self.server.state, 'auth') - -    def testFailedLogin(self): -        def login(): -            d = self.client.login('testuser', 'wrong-password') -            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.account, None) -        self.assertEqual(self.server.state, 'unauth') - - -    def testLoginRequiringQuoting(self): -        self.server._username = '{test}user' -        self.server._password = '{test}password' - -        def login(): -            d = self.client.login('{test}user', '{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.account, SimpleLEAPServer.theAccount) -        self.assertEqual(self.server.state, 'auth') - - -    def testNamespace(self): -        self.namespaceArgs = None -        def login(): -            return self.client.login('testuser', 'password-test') -        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 testSelect(self): -        SimpleLEAPServer.theAccount.addMailbox('test-mailbox') -        self.selectedArgs = None - -        def login(): -            return self.client.login('testuser', 'password-test') - -        def select(): -            def selected(args): -                self.selectedArgs = args -                self._cbStopClient(None) -            d = self.client.select('test-mailbox') -            d.addCallback(selected) -            return d +# +# TestCases +# -        d1 = self.connected.addCallback(strip(login)) -        d1.addCallback(strip(select)) -        d1.addErrback(self._ebGeneral) -        d2 = self.loopback() -        return defer.gatherResults([d1, d2]).addCallback(self._cbTestSelect) - -    def _cbTestSelect(self, ignored): -        mbox = SimpleLEAPServer.theAccount.mailboxes['TEST-MAILBOX'] -        self.assertEqual(self.server.mbox, mbox) -        self.assertEqual(self.selectedArgs, { -            'EXISTS': 9, 'RECENT': 3, 'UIDVALIDITY': 42, -            'FLAGS': ('\\Seen', '\\Answered', '\\Flagged', -                      '\\Deleted', '\\Draft', '\\Recent', 'List'), -            'READ-WRITE': 1 -        }) - -    def test_examine(self): +class MessageCollectionTestCase(IMAP4HelperMixin, unittest.TestCase): +    """ +    Tests for the MessageCollection class +    """ +    def setUp(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. +        setUp method for each test +        We override mixin method since we are only testing +        MessageCollection interface in this particular TestCase          """ -        SimpleLEAPServer.theAccount.addMailbox('test-mailbox') -        self.examinedArgs = None - -        def login(): -            return self.client.login('testuser', 'password-test') +        self.messages = MessageCollection("testmbox", self._soledad._db) -        def examine(): -            def examined(args): -                self.examinedArgs = args -                self._cbStopClient(None) -            d = self.client.examine('test-mailbox') -            d.addCallback(examined) -            return d +    def tearDown(self): +        """ +        tearDown method for each test +        Delete the message collection +        """ +        del self.messages -        d1 = self.connected.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 testEmptyMessage(self): +        """ +        Test empty message and collection +        """ +        em = self.messages.get_empty_msg() +        self.assertEqual(em, +                         {"subject": "", "seen": False, +                          "flags": [], "mailbox": "inbox", +                          "mbox-uid": 1, +                          "raw": ""}) +        self.assertEqual(self.messages.count(), 0) + +    def testFilterByMailbox(self): +        """ +        Test that queries filter by selected mailbox +        """ +        mc = self.messages +        mc.add_msg('', subject="test1") +        mc.add_msg('', subject="test2") +        mc.add_msg('', subject="test3") +        self.assertEqual(self.messages.count(), 3) + +        newmsg = mc.get_empty_msg() +        newmsg['mailbox'] = "mailbox/foo" +        newmsg['subject'] = "test another mailbox" +        mc.db.create_doc(newmsg) +        self.assertEqual(mc.count(), 3) +        self.assertEqual(len(mc.db.get_from_index(mc.MAILBOX_INDEX, "*")), +                         4) + + +class LeapIMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): +    """ +    Tests for the generic behavior of the LeapIMAP4Server +    which, right now, it's just implemented in this test file as +    SimpleLEAPServer. 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. +    """ -    def _cbTestExamine(self, ignored): -        mbox = SimpleLEAPServer.theAccount.mailboxes['TEST-MAILBOX'] -        self.assertEqual(self.server.mbox, mbox) -        self.assertEqual(self.examinedArgs, { -            'EXISTS': 9, 'RECENT': 3, 'UIDVALIDITY': 42, -            'FLAGS': ('\\Seen', '\\Answered', '\\Flagged', -                      '\\Deleted', '\\Draft', '\\Recent', 'List'), -            'READ-WRITE': False}) +    # +    # mailboxes operations +    #      def testCreate(self): -        succeed = ('testbox', 'test/box', 'test/', 'test/box/box', 'INBOX') +        """ +        Test whether we can create mailboxes +        """ +        succeed = ('testbox', 'test/box', 'test/', 'test/box/box', 'FOOBOX')          fail = ('testbox', 'test/box')          def cb(): @@ -498,13 +477,17 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):      def _cbTestCreate(self, ignored, succeed, fail):          self.assertEqual(self.result, [1] * len(succeed) + [0] * len(fail)) -        mbox = SimpleLEAPServer.theAccount.mailboxes.keys() -        answers = ['inbox', 'testbox', 'test/box', 'test', 'test/box/box'] + +        mbox = SimpleLEAPServer.theAccount.mailboxes +        answers = ['foobox', 'testbox', 'test/box', 'test', 'test/box/box']          mbox.sort()          answers.sort()          self.assertEqual(mbox, [a.upper() for a in answers])      def testDelete(self): +        """ +        Test whether we can delete mailboxes +        """          SimpleLEAPServer.theAccount.addMailbox('delete/me')          def login(): @@ -518,11 +501,16 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          d1.addCallbacks(self._cbStopClient, self._ebGeneral)          d2 = self.loopback()          d = defer.gatherResults([d1, d2]) -        d.addCallback(lambda _: -                      self.assertEqual(SimpleLEAPServer.theAccount.mailboxes.keys(), [])) +        d.addCallback( +            lambda _: self.assertEqual( +                SimpleLEAPServer.theAccount.mailboxes, []))          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(): @@ -545,12 +533,16 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          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 inbox' +        """          def login():              return self.client.login('testuser', 'password-test')          def delete():              return self.client.delete('delete/me') +            self.failure = failure          def deleteFailed(failure):              self.failure = failure @@ -562,13 +554,17 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          d2 = self.loopback()          d = defer.gatherResults([d1, d2])          d.addCallback(lambda _: self.assertEqual(str(self.failure.value), -                                                  'No such mailbox')) +                                                 'No such mailbox'))          return d      def testIllegalDelete(self): -        m = SoledadMailbox() -        m.flags = (r'\Noselect',) -        SimpleLEAPServer.theAccount.addMailbox('delete', m) +        """ +        Try deleting a mailbox with sub-folders, and \NoSelect flag set. +        An exception is expected +        """ +        SimpleLEAPServer.theAccount.addMailbox('delete') +        to_delete = SimpleLEAPServer.theAccount.getMailbox('delete') +        to_delete.setFlags((r'\Noselect',))          SimpleLEAPServer.theAccount.addMailbox('delete/me')          def login(): @@ -593,6 +589,9 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          return d      def testRename(self): +        """ +        Test whether we can rename a mailbox +        """          SimpleLEAPServer.theAccount.addMailbox('oldmbox')          def login(): @@ -608,11 +607,15 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          d = defer.gatherResults([d1, d2])          d.addCallback(lambda _:                        self.assertEqual( -                        SimpleLEAPServer.theAccount.mailboxes.keys(), -                        ['NEWNAME'])) +                          SimpleLEAPServer.theAccount.mailboxes, +                          ['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(): @@ -632,10 +635,13 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          d = defer.gatherResults([d1, d2])          d.addCallback(lambda _:                        self.failUnless(isinstance( -                        self.stashed, failure.Failure))) +                          self.stashed, failure.Failure)))          return d      def testHierarchicalRename(self): +        """ +        Try to rename hierarchical mailboxes +        """          SimpleLEAPServer.theAccount.create('oldmbox/m1')          SimpleLEAPServer.theAccount.create('oldmbox/m2') @@ -653,13 +659,15 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          return d.addCallback(self._cbTestHierarchicalRename)      def _cbTestHierarchicalRename(self, ignored): -        mboxes = SimpleLEAPServer.theAccount.mailboxes.keys() +        mboxes = SimpleLEAPServer.theAccount.mailboxes          expected = ['newname', 'newname/m1', 'newname/m2']          mboxes.sort()          self.assertEqual(mboxes, [s.upper() for s in expected])      def testSubscribe(self): - +        """ +        Test whether we can mark a mailbox as subscribed to +        """          def login():              return self.client.login('testuser', 'password-test') @@ -672,14 +680,21 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          d2 = self.loopback()          d = defer.gatherResults([d1, d2])          d.addCallback(lambda _: -                      self.assertEqual(SimpleLEAPServer.theAccount.subscriptions, -                                        ['THIS/MBOX'])) +                      self.assertEqual( +                          SimpleLEAPServer.theAccount.subscriptions, +                          ['THIS/MBOX']))          return d      def testUnsubscribe(self): -        SimpleLEAPServer.theAccount.subscriptions = ['THIS/MBOX', 'THAT/MBOX'] +        """ +        Test whether we can unsubscribe from a set of mailboxes +        """ +        SimpleLEAPServer.theAccount.subscribe('THIS/MBOX') +        SimpleLEAPServer.theAccount.subscribe('THAT/MBOX') +          def login():              return self.client.login('testuser', 'password-test') +          def unsubscribe():              return self.client.unsubscribe('this/mbox') @@ -689,14 +704,255 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          d2 = self.loopback()          d = defer.gatherResults([d1, d2])          d.addCallback(lambda _: -                      self.assertEqual(SimpleLEAPServer.theAccount.subscriptions, -                                        ['THAT/MBOX'])) +                      self.assertEqual( +                          SimpleLEAPServer.theAccount.subscriptions, +                          ['THAT/MBOX'])) +        return d + +    def testSelect(self): +        """ +        Try to select a mailbox +        """ +        self.server.theAccount.addMailbox('TESTMAILBOX-SELECT', creation_ts=42) +        self.selectedArgs = None + +        def login(): +            return self.client.login('testuser', 'password-test') + +        def select(): +            def selected(args): +                self.selectedArgs = args +                self._cbStopClient(None) +            d = self.client.select('TESTMAILBOX-SELECT') +            d.addCallback(selected) +            return d + +        d1 = self.connected.addCallback(strip(login)) +        d1.addCallback(strip(select)) +        d1.addErrback(self._ebGeneral) + +        d2 = self.loopback() +        return defer.gatherResults([d1, d2]).addCallback(self._cbTestSelect) + +    def _cbTestSelect(self, ignored): +        mbox = SimpleLEAPServer.theAccount.getMailbox('TESTMAILBOX-SELECT') +        self.assertEqual(self.server.mbox.messages.mbox, mbox.messages.mbox) +        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.addCallback( +            strip(getCaps)).addErrback(self._ebGeneral) +        d = defer.gatherResults([self.loopback(), d1]) +        expected = {'IMAP4rev1': None, 'NAMESPACE': None, 'IDLE': None} + +        return d.addCallback(lambda _: self.assertEqual(expected, caps)) + +    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, 'AUTH': ['CRAM-MD5']} + +        return d.addCallback(lambda _: self.assertEqual(expCap, caps)) + +    # +    # 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('testuser', 'password-test') +            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.account, SimpleLEAPServer.theAccount) +        self.assertEqual(self.server.state, 'auth') + +    def testFailedLogin(self): +        """ +        Test bad login +        """ +        def login(): +            d = self.client.login('testuser', 'wrong-password') +            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.account, None) +        self.assertEqual(self.server.state, 'unauth') + +    def testLoginRequiringQuoting(self): +        """ +        Test login requiring quoting +        """ +        self.server._username = '{test}user' +        self.server._password = '{test}password' + +        def login(): +            d = self.client.login('{test}user', '{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.account, SimpleLEAPServer.theAccount) +        self.assertEqual(self.server.state, 'auth') + +    # +    # Inspection +    # + +    def testNamespace(self): +        """ +        Test retrieving namespace +        """ +        self.namespaceArgs = None + +        def login(): +            return self.client.login('testuser', 'password-test') + +        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. +        """ +        self.server.theAccount.addMailbox('test-mailbox-e', +                                          creation_ts=42) +        #import ipdb; ipdb.set_trace() + +        self.examinedArgs = None + +        def login(): +            return self.client.login('testuser', 'password-test') + +        def examine(): +            def examined(args): +                self.examinedArgs = args +                self._cbStopClient(None) +            d = self.client.examine('test-mailbox-e') +            d.addCallback(examined) +            return d + +        d1 = self.connected.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): +        mbox = self.server.theAccount.getMailbox('TEST-MAILBOX-E') +        self.assertEqual(self.server.mbox.messages.mbox, mbox.messages.mbox) +        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): -        SimpleLEAPServer.theAccount.addMailbox('root/subthing') -        SimpleLEAPServer.theAccount.addMailbox('root/another-thing') -        SimpleLEAPServer.theAccount.addMailbox('non-root/subthing') +        SimpleLEAPServer.theAccount.addMailbox('root/subthingl', +                                               creation_ts=42) +        SimpleLEAPServer.theAccount.addMailbox('root/another-thing', +                                               creation_ts=42) +        SimpleLEAPServer.theAccount.addMailbox('non-root/subthing', +                                               creation_ts=42)          def login():              return self.client.login('testuser', 'password-test') @@ -713,37 +969,51 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          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([ -                (SoledadMailbox.flags, "/", "ROOT/SUBTHING"), -                (SoledadMailbox.flags, "/", "ROOT/ANOTHER-THING") +                (SoledadMailbox.INIT_FLAGS, "/", "ROOT/SUBTHINGL"), +                (SoledadMailbox.INIT_FLAGS, "/", "ROOT/ANOTHER-THING")              ])          ))          return d +    # XXX implement subscriptions +    '''      def testLSub(self): -        SimpleLEAPServer.theAccount.subscribe('ROOT/SUBTHING') +        """ +        Test LSub command +        """ +        SimpleLEAPServer.theAccount.subscribe('ROOT/SUBTHINGL')          def lsub():              return self.client.lsub('root', '%')          d = self._listSetup(lsub)          d.addCallback(self.assertEqual, -                      [(SoledadMailbox.flags, "/", "ROOT/SUBTHING")]) +                      [(SoledadMailbox.INIT_FLAGS, "/", "ROOT/SUBTHINGL")])          return d +    '''      def testStatus(self): -        SimpleLEAPServer.theAccount.addMailbox('root/subthing') +        """ +        Test Status command +        """ +        SimpleLEAPServer.theAccount.addMailbox('root/subthings') +        # XXX FIXME ---- should populate this a little bit, +        # with unseen etc...          def login():              return self.client.login('testuser', 'password-test')          def status():              return self.client.status( -                'root/subthing', 'MESSAGES', 'UIDNEXT', 'UNSEEN') +                'root/subthings', 'MESSAGES', 'UIDNEXT', 'UNSEEN')          def statused(result):              self.statused = result @@ -757,11 +1027,14 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          d = defer.gatherResults([d1, d2])          d.addCallback(lambda _: self.assertEqual(              self.statused, -            {'MESSAGES': 9, 'UIDNEXT': '10', 'UNSEEN': 4} +            {'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('testuser', 'password-test') @@ -793,7 +1066,14 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):              ('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)          SimpleLEAPServer.theAccount.addMailbox('root/subthing') @@ -805,7 +1085,7 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):              return self.client.append(                  'root/subthing',                  message, -                ('\\SEEN', '\\DELETED'), +                ['\\SEEN', '\\DELETED'],                  'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)',              ) @@ -817,15 +1097,24 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          return d.addCallback(self._cbTestFullAppend, infile)      def _cbTestFullAppend(self, ignored, infile): -        mb = SimpleLEAPServer.theAccount.mailboxes['ROOT/SUBTHING'] +        mb = SimpleLEAPServer.theAccount.getMailbox('ROOT/SUBTHING')          self.assertEqual(1, len(mb.messages)) + +        #import ipdb; ipdb.set_trace()          self.assertEqual( -            (['\\SEEN', '\\DELETED'], 'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)', 0), -            mb.messages[0][1:] -        ) -        self.assertEqual(open(infile).read(), mb.messages[0][0].getvalue()) +            ['\\SEEN', '\\DELETED'], +            mb.messages[1]['flags']) + +        self.assertEqual( +            'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)', +            mb.messages[1]['date']) + +        self.assertEqual(open(infile).read(), mb.messages[1]['raw'])      def testPartialAppend(self): +        """ +        Test partially appending a message to the mailbox +        """          infile = util.sibpath(__file__, 'rfc822.message')          message = open(infile)          SimpleLEAPServer.theAccount.addMailbox('PARTIAL/SUBTHING') @@ -838,7 +1127,8 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):              return self.client.sendCommand(                  imap4.Command(                      'APPEND', -                    'PARTIAL/SUBTHING (\\SEEN) "Right now" {%d}' % os.path.getsize(infile), +                    'PARTIAL/SUBTHING (\\SEEN) "Right now" ' +                    '{%d}' % os.path.getsize(infile),                      (), self.client._IMAP4Client__cbContinueAppend, message                  )              ) @@ -850,15 +1140,20 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          return d.addCallback(self._cbTestPartialAppend, infile)      def _cbTestPartialAppend(self, ignored, infile): -        mb = SimpleLEAPServer.theAccount.mailboxes['PARTIAL/SUBTHING'] +        mb = SimpleLEAPServer.theAccount.getMailbox('PARTIAL/SUBTHING')          self.assertEqual(1, len(mb.messages))          self.assertEqual( -            (['\\SEEN'], 'Right now', 0), -            mb.messages[0][1:] +            ['\\SEEN',], +            mb.messages[1]['flags']          ) -        self.assertEqual(open(infile).read(), mb.messages[0][0].getvalue()) +        self.assertEqual( +            'Right now', mb.messages[1]['date']) +        self.assertEqual(open(infile).read(), mb.messages[1]['raw'])      def testCheck(self): +        """ +        Test check command +        """          SimpleLEAPServer.theAccount.addMailbox('root/subthing')          def login(): @@ -879,19 +1174,25 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):          # Okay, that was fun      def testClose(self): -        m = SoledadMailbox() -        m.messages = [ -            ('Message 1', ('\\Deleted', 'AnotherFlag'), None, 0), -            ('Message 2', ('AnotherFlag',), None, 1), -            ('Message 3', ('\\Deleted',), None, 2), -        ] -        SimpleLEAPServer.theAccount.addMailbox('mailbox', m) +        """ +        Test closing the mailbox. We expect to get deleted all messages flagged +        as such. +        """ +        name = 'mailbox-close' +        self.server.theAccount.addMailbox(name) +        #import ipdb; ipdb.set_trace() + +        m = SimpleLEAPServer.theAccount.getMailbox(name) +        m.messages.add_msg('', subject="Message 1", +                           flags=('\\Deleted', 'AnotherFlag')) +        m.messages.add_msg('', subject="Message 2", flags=('AnotherFlag',)) +        m.messages.add_msg('', subject="Message 3", flags=('\\Deleted',))          def login():              return self.client.login('testuser', 'password-test')          def select(): -            return self.client.select('mailbox') +            return self.client.select(name)          def close():              return self.client.close() @@ -905,24 +1206,29 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):      def _cbTestClose(self, ignored, m):          self.assertEqual(len(m.messages), 1) -        self.assertEqual(m.messages[0], -            ('Message 2', ('AnotherFlag',), None, 1)) +        self.assertEqual( +            m.messages[1]['subject'], +            'Message 2') +          self.failUnless(m.closed)      def testExpunge(self): -        m = SoledadMailbox() -        m.messages = [ -            ('Message 1', ('\\Deleted', 'AnotherFlag'), None, 0), -            ('Message 2', ('AnotherFlag',), None, 1), -            ('Message 3', ('\\Deleted',), None, 2), -        ] -        SimpleLEAPServer.theAccount.addMailbox('mailbox', m) +        """ +        Test expunge command +        """ +        name = 'mailbox-expunge' +        SimpleLEAPServer.theAccount.addMailbox(name) +        m = SimpleLEAPServer.theAccount.getMailbox(name) +        m.messages.add_msg('', subject="Message 1", +                           flags=('\\Deleted', 'AnotherFlag')) +        m.messages.add_msg('', subject="Message 2", flags=('AnotherFlag',)) +        m.messages.add_msg('', subject="Message 3", flags=('\\Deleted',))          def login():              return self.client.login('testuser', 'password-test')          def select(): -            return self.client.select('mailbox') +            return self.client.select('mailbox-expunge')          def expunge():              return self.client.expunge() @@ -943,15 +1249,16 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase):      def _cbTestExpunge(self, ignored, m):          self.assertEqual(len(m.messages), 1) -        self.assertEqual(m.messages[0], -            ('Message 2', ('AnotherFlag',), None, 1)) - -        self.assertEqual(self.results, [0, 2]) - +        self.assertEqual( +            m.messages[1]['subject'], +            'Message 2') +        self.assertEqual(self.results, [0, 1]) +        # XXX fix this thing with the indexes...  class IMAP4ServerSearchTestCase(IMAP4HelperMixin, unittest.TestCase):      """      Tests for the behavior of the search_* functions in L{imap4.IMAP4Server}.      """ +    # XXX coming soon to your screens!      pass | 
