diff options
| author | Kali Kaneko <kali@leap.se> | 2014-11-25 15:04:26 +0100 | 
|---|---|---|
| committer | Kali Kaneko <kali@leap.se> | 2015-02-11 14:05:42 -0400 | 
| commit | 8ff31dd195e1dd61f28cfa1706d529ad7d33276a (patch) | |
| tree | 3849c3d44366578b5e724a7d78312af6b44179ae /mail/src | |
| parent | 56d91c45d4107859a2a9a58061a54b4d2c5d27b3 (diff) | |
Serializable Models + Soledad Adaptor
Diffstat (limited to 'mail/src')
20 files changed, 2415 insertions, 768 deletions
| diff --git a/mail/src/leap/mail/adaptors/__init__.py b/mail/src/leap/mail/adaptors/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/mail/src/leap/mail/adaptors/__init__.py diff --git a/mail/src/leap/mail/adaptors/models.py b/mail/src/leap/mail/adaptors/models.py new file mode 100644 index 00000000..16480594 --- /dev/null +++ b/mail/src/leap/mail/adaptors/models.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# models.py +# Copyright (C) 2014 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Generic Models to be used by the Document Adaptors. +""" +import copy + + +class SerializableModel(object): +    """ +    A Generic document model, that can be serialized into a dictionary. + +    Subclasses of this `SerializableModel` are meant to be added as class +    attributes of classes inheriting from DocumentWrapper. + +    A subclass __meta__ of this SerializableModel might exist, and contain info +    relative to particularities of this model. + +    For instance, the use of `__meta__.index` marks the existence of a primary +    index in the model, which will be used to do unique queries (in which case +    all the other indexed fields in the underlying document will be filled with +    the default info contained in the model definition). +    """ + +    @classmethod +    def serialize(klass): +        """ +        Get a dictionary representation of the public attributes in the model +        class. To avoid collisions with builtin functions, any occurrence of an +        attribute ended in '_' (like 'type_') will be normalized by removing +        the trailing underscore. + +        This classmethod is used from within the serialized method of a +        DocumentWrapper instance: it provides defaults for the +        empty document. +        """ +        assert isinstance(klass, type) +        return _normalize_dict(klass.__dict__) + + +class DocumentWrapper(object): +    """ +    A Wrapper object that can be manipulated, passed around, and serialized in +    a format that the store understands. +    It is related to a SerializableModel, which must be specified as the +    ``model`` class attribute.  The instance of this DocumentWrapper will not +    allow any other *public* attributes than those defined in the corresponding +    model. +    """ +    # TODO we could do some very basic type checking here +    # TODO set a dirty flag (on __setattr__, whenever the value is != from +    # before) +    # TODO we could enforce the existence of a correct "model" attribute +    # in some other way (other than in the initializer) + +    def __init__(self, **kwargs): +        if not getattr(self, 'model', None): +            raise RuntimeError( +                'DocumentWrapper class needs a model attribute') + +        defaults = self.model.serialize() + +        if kwargs: +            values = copy.deepcopy(defaults) +            values.update(kwargs) +        else: +            values = defaults + +        for k, v in values.items(): +            k = k.replace('-', '_') +            setattr(self, k, v) + +    def __setattr__(self, attr, value): +        normalized = _normalize_dict(self.model.__dict__) +        if not attr.startswith('_') and attr not in normalized: +            raise RuntimeError( +                "Cannot set attribute because it's not defined " +                "in the model: %s" % attr) +        object.__setattr__(self, attr, value) + +    def serialize(self): +        return _normalize_dict(self.__dict__) + +    def create(self): +        raise NotImplementedError() + +    def update(self): +        raise NotImplementedError() + +    def delete(self): +        raise NotImplementedError() + +    @classmethod +    def get_or_create(self): +        raise NotImplementedError() + +    @classmethod +    def get_all(self): +        raise NotImplementedError() + + +def _normalize_dict(_dict): +    items = _dict.items() +    not_callable = lambda (k, v): not callable(v) +    not_private = lambda(k, v): not k.startswith('_') +    for cond in not_callable, not_private: +        items = filter(cond, items) +    items = [(k, v) if not k.endswith('_') else (k[:-1], v) +             for (k, v) in items] +    items = [(k.replace('-', '_'), v) for (k, v) in items] +    return dict(items) diff --git a/mail/src/leap/mail/adaptors/soledad.py b/mail/src/leap/mail/adaptors/soledad.py new file mode 100644 index 00000000..2e25f047 --- /dev/null +++ b/mail/src/leap/mail/adaptors/soledad.py @@ -0,0 +1,723 @@ +# -*- coding: utf-8 -*- +# soledad.py +# Copyright (C) 2014 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Soledadad MailAdaptor module. +""" +import re +from collections import defaultdict +from email import message_from_string + +from pycryptopp.hash import sha256 +from twisted.internet import defer +from zope.interface import implements + +from leap.common.check import leap_assert, leap_assert_type + +from leap.mail import walk +from leap.mail.adaptors import soledad_indexes as indexes +from leap.mail.constants import INBOX_NAME +from leap.mail.adaptors import models +from leap.mail.imap.mailbox import normalize_mailbox +from leap.mail.utils import lowerdict, first +from leap.mail.utils import stringify_parts_map +from leap.mail.interfaces import IMailAdaptor, IMessageWrapper + + +# TODO +# [ ] Convenience function to create mail specifying subject, date, etc? + + +_MSGID_PATTERN = r"""<([\w@.]+)>""" +_MSGID_RE = re.compile(_MSGID_PATTERN) + + +class DuplicatedDocumentError(Exception): +    """ +    Raised when a duplicated document is detected. +    """ +    pass + + +class SoledadDocumentWrapper(models.DocumentWrapper): +    """ +    A Wrapper object that can be manipulated, passed around, and serialized in +    a format that the Soledad Store understands. + +    It ensures atomicity of the document operations on creation, update and +    deletion. +    """ + +    # TODO we could also use a _dirty flag (in models) + +    # We keep a dictionary with DeferredLocks, that will be +    # unique to every subclass of SoledadDocumentWrapper. +    _k_locks = defaultdict(defer.DeferredLock) + +    @classmethod +    def _get_klass_lock(cls): +        """ +        Get a DeferredLock that is unique for this subclass name. +        Used to lock the access to indexes in the `get_or_create` call +        for a particular DocumentWrapper. +        """ +        return cls._k_locks[cls.__name__] + +    def __init__(self, **kwargs): +        doc_id = kwargs.pop('doc_id', None) +        self._doc_id = doc_id +        self._lock = defer.DeferredLock() +        super(SoledadDocumentWrapper, self).__init__(**kwargs) + +    @property +    def doc_id(self): +        return self._doc_id + +    def create(self, store): +        """ +        Create the documents for this wrapper. +        Since this method will not check for duplication, the +        responsibility of avoiding duplicates is left to the caller. + +        You might be interested in using `get_or_create` classmethod +        instead (that's the preferred way of creating documents from +        the wrapper object). + +        :return: a deferred that will fire when the underlying +                 Soledad document has been created. +        :rtype: Deferred +        """ +        leap_assert(self._doc_id is None, +                    "This document already has a doc_id!") + +        def update_doc_id(doc): +            self._doc_id = doc.doc_id +            return doc +        d = store.create_doc(self.serialize()) +        d.addCallback(update_doc_id) +        return d + +    def update(self, store): +        """ +        Update the documents for this wrapper. + +        :return: a deferred that will fire when the underlying +                 Soledad document has been updated. +        :rtype: Deferred +        """ +        # the deferred lock guards against revision conflicts +        return self._lock.run(self._update, store) + +    def _update(self, store): +        leap_assert(self._doc_id is not None, +                    "Need to create doc before updating") + +        def update_and_put_doc(doc): +            doc.content.update(self.serialize()) +            return store.put_doc(doc) + +        d = store.get_doc(self._doc_id) +        d.addCallback(update_and_put_doc) +        return d + +    def delete(self, store): +        """ +        Delete the documents for this wrapper. + +        :return: a deferred that will fire when the underlying +                 Soledad document has been deleted. +        :rtype: Deferred +        """ +        # the deferred lock guards against conflicts while updating +        return self._lock.run(self._delete, store) + +    def _delete(self, store): +        leap_assert(self._doc_id is not None, +                    "Need to create doc before deleting") +        # XXX might want to flag this DocumentWrapper to avoid +        # updating it by mistake. This could go in models.DocumentWrapper + +        def delete_doc(doc): +            return store.delete_doc(doc) + +        d = store.get_doc(self._doc_id) +        d.addCallback(delete_doc) +        return d + +    @classmethod +    def get_or_create(cls, store, index, value): +        """ +        Get a unique DocumentWrapper by index, or create a new one if the +        matching query does not exist. + +        :param index: the primary index for the model. +        :type index: str +        :param value: the value to query the primary index. +        :type value: str + +        :return: a deferred that will be fired with the SoledadDocumentWrapper +                 matching the index query, either existing or just created. +        :rtype: Deferred +        """ +        return cls._get_klass_lock().run( +            cls._get_or_create, store, index, value) + +    @classmethod +    def _get_or_create(cls, store, index, value): +        assert store is not None +        assert index is not None +        assert value is not None + +        def get_main_index(): +            try: +                return cls.model.__meta__.index +            except AttributeError: +                raise RuntimeError("The model is badly defined") + +        def try_to_get_doc_from_index(indexes): +            values = [] +            idx_def = dict(indexes)[index] +            if len(idx_def) == 1: +                values = [value] +            else: +                main_index = get_main_index() +                fields = cls.model.serialize() +                for field in idx_def: +                    if field == main_index: +                        values.append(value) +                    else: +                        values.append(fields[field]) +            d = store.get_from_index(index, *values) +            return d + +        def get_first_doc_if_any(docs): +            if not docs: +                return None +            if len(docs) > 1: +                raise DuplicatedDocumentError +            return docs[0] + +        def wrap_existing_or_create_new(doc): +            if doc: +                return cls(doc_id=doc.doc_id, **doc.content) +            else: +                return create_and_wrap_new_doc() + +        def create_and_wrap_new_doc(): +            # XXX use closure to store indexes instead of +            # querying for them again. +            d = store.list_indexes() +            d.addCallback(get_wrapper_instance_from_index) +            d.addCallback(return_wrapper_when_created) +            return d + +        def get_wrapper_instance_from_index(indexes): +            init_values = {} +            idx_def = dict(indexes)[index] +            if len(idx_def) == 1: +                init_value = {idx_def[0]: value} +                return cls(**init_value) +            main_index = get_main_index() +            fields = cls.model.serialize() +            for field in idx_def: +                if field == main_index: +                    init_values[field] = value +                else: +                    init_values[field] = fields[field] +            return cls(**init_values) + +        def return_wrapper_when_created(wrapper): +            d = wrapper.create(store) +            d.addCallback(lambda doc: wrapper) +            return d + +        d = store.list_indexes() +        d.addCallback(try_to_get_doc_from_index) +        d.addCallback(get_first_doc_if_any) +        d.addCallback(wrap_existing_or_create_new) +        return d + +    @classmethod +    def get_all(cls, store): +        """ +        Get a collection of wrappers around all the documents belonging +        to this kind. + +        For this to work, the model.__meta__ needs to include a tuple with +        the index to be used for listing purposes, and which is the field to be +        used to query the index. + +        Note that this method only supports indexes of a single field at the +        moment. It also might be too expensive to return all the documents +        matching the query, so handle with care. + +        class __meta__(object): +            index = "name" +            list_index = ("by-type", "type_") + +        :return: a deferred that will be fired with an iterable containing +                 as many SoledadDocumentWrapper are matching the index defined +                 in the model as the `list_index`. +        :rtype: Deferred +        """ +        # TODO +        # [ ] extend support to indexes with n-ples +        # [ ] benchmark the cost of querying and returning indexes in a big +        #     database. This might badly need pagination before being put to +        #     serious use. +        return cls._get_klass_lock().run(cls._get_all, store) + +    @classmethod +    def _get_all(cls, store): +        try: +            list_index, list_attr = cls.model.__meta__.list_index +        except AttributeError: +            raise RuntimeError("The model is badly defined: no list_index") +        try: +            index_value = getattr(cls.model, list_attr) +        except AttributeError: +            raise RuntimeError("The model is badly defined: " +                               "no attribute matching list_index") + +        def wrap_docs(docs): +            return (cls(doc_id=doc.doc_id, **doc.content) for doc in docs) + +        d = store.get_from_index(list_index, index_value) +        d.addCallback(wrap_docs) +        return d + +    # TODO +    # [ ] get_count() ??? + +    def __repr__(self): +        try: +            idx = getattr(self, self.model.__meta__.index) +        except AttributeError: +            idx = "" +        return "<%s: %s (%s)>" % (self.__class__.__name__, +                                  idx, self._doc_id) + + +# +# Message documents +# + +class FlagsDocWrapper(SoledadDocumentWrapper): + +    class model(models.SerializableModel): +        type_ = "flags" +        chash = "" + +        mbox = "inbox" +        seen = False +        deleted = False +        recent = False +        multi = False +        flags = [] +        tags = [] +        size = 0 + +        class __meta__(object): +            index = "mbox" + + +class HeaderDocWrapper(SoledadDocumentWrapper): + +    class model(models.SerializableModel): +        type_ = "head" +        chash = "" + +        date = "" +        subject = "" +        headers = {} +        part_map = {} +        body = ""  # link to phash of body +        msgid = "" +        multi = False + +        class __meta__(object): +            index = "chash" + + +class ContentDocWrapper(SoledadDocumentWrapper): + +    class model(models.SerializableModel): +        type_ = "cnt" +        phash = "" + +        ctype = ""  # XXX index by ctype too? +        lkf = []  # XXX not implemented yet! +        raw = "" + +        content_disposition = "" +        content_transfer_encoding = "" +        content_type = "" + +        class __meta__(object): +            index = "phash" + + +class MessageWrapper(object): + +    # TODO generalize wrapper composition? +    # This could benefit of a DeferredLock to create/update all the +    # documents at the same time maybe, and defend against concurrent updates? + +    implements(IMessageWrapper) + +    def __init__(self, fdoc, hdoc, cdocs=None): +        """ +        Need at least a flag-document and a header-document to instantiate a +        MessageWrapper. Content-documents can be retrieved lazily. + +        cdocs, if any, should be a dictionary in which the keys are ascending +        integers, beginning at one, and the values are dictionaries with the +        content of the content-docs. +        """ +        self.fdoc = FlagsDocWrapper(**fdoc) +        self.hdoc = HeaderDocWrapper(**hdoc) +        if cdocs is None: +            cdocs = {} +        cdocs_keys = cdocs.keys() +        assert sorted(cdocs_keys) == range(1, len(cdocs_keys) + 1) +        self.cdocs = dict([(key, ContentDocWrapper(**doc)) for (key, doc) in +                           cdocs.items()]) + +    def create(self, store): +        """ +        Create all the parts for this message in the store. +        """ +        leap_assert(self.cdocs, +                    "Need non empty cdocs to create the " +                    "MessageWrapper documents") +        leap_assert(self.fdoc.doc_id is None, +                    "Cannot create: fdoc has a doc_id") + +        # TODO I think we need to tolerate the no hdoc.doc_id case, for when we +        # are doing a copy to another mailbox. +        leap_assert(self.hdoc.doc_id is None, +                    "Cannot create: hdoc has a doc_id") +        d = [] +        d.append(self.fdoc.create(store)) +        d.append(self.hdoc.create(store)) +        for cdoc in self.cdocs.values(): +            if cdoc.doc_id is not None: +                # we could be just linking to an existing +                # content-doc. +                continue +            d.append(cdoc.create(store)) +        return defer.gatherResults(d) + +    def update(self, store): +        """ +        Update the only mutable parts, which are within the flags document. +        """ +        return self.fdoc.update(store) + +    def delete(self, store): +        # Eventually this would have to do the duplicate search or send for the +        # garbage collector. At least the fdoc can be unlinked. +        raise NotImplementedError() + +# +# Mailboxes +# + + +class MailboxWrapper(SoledadDocumentWrapper): + +    class model(models.SerializableModel): +        type_ = "mbox" +        mbox = INBOX_NAME +        flags = [] +        closed = False +        subscribed = False +        rw = True + +        class __meta__(object): +            index = "mbox" +            list_index = (indexes.TYPE_IDX, 'type_') + + +# +# Soledad Adaptor +# + +# TODO make this an interface? +class SoledadIndexMixin(object): +    """ +    this will need a class attribute `indexes`, that is a dictionary containing +    the index definitions for the underlying u1db store underlying soledad. + +    It needs to be in the following format: +    {'index-name': ['field1', 'field2']} +    """ +    # TODO could have a wrapper class for indexes, supporting introspection +    # and __getattr__ +    indexes = {} + +    store_ready = False +    _index_creation_deferreds = [] + +    # TODO we might want to move this logic to soledad itself +    # so that each application can pass a set of indexes for their data model. +    # TODO check also the decorator used in keymanager for waiting for indexes +    # to be ready. + +    def initialize_store(self, store): +        """ +        Initialize the indexes in the database. + +        :param store: store +        :returns: a Deferred that will fire when the store is correctly +                  initialized. +        :rtype: deferred +        """ +        # TODO I think we *should* get another deferredLock in here, but +        # global to the soledad namespace, to protect from several points +        # initializing soledad indexes at the same time. + +        leap_assert(store, "Need a store") +        leap_assert_type(self.indexes, dict) +        self._index_creation_deferreds = [] + +        def _on_indexes_created(ignored): +            self.store_ready = True + +        def _create_index(name, expression): +            d = store.create_index(name, *expression) +            self._index_creation_deferreds.append(d) + +        def _create_indexes(db_indexes): +            db_indexes = dict(db_indexes) + +            for name, expression in self.indexes.items(): +                if name not in db_indexes: +                    # The index does not yet exist. +                    _create_index(name, expression) +                    continue + +                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. +                d1 = store.delete_index(name) +                d1.addCallback(lambda _: _create_index(name, expression)) + +            all_created = defer.gatherResults(self._index_creation_deferreds) +            all_created.addCallback(_on_indexes_created) +            return all_created + +        # Ask the database for currently existing indexes, and create them +        # if not found. +        d = store.list_indexes() +        d.addCallback(_create_indexes) +        return d + + +class SoledadMailAdaptor(SoledadIndexMixin): + +    implements(IMailAdaptor) +    store = None + +    indexes = indexes.MAIL_INDEXES + +    # Message handling + +    def get_msg_from_string(self, MessageClass, raw_msg): +        """ +        Get an instance of a MessageClass initialized with a MessageWrapper +        that contains all the parts obtained from parsing the raw string for +        the message. + +        :param MessageClass: any Message class that can be initialized passing +                             an instance of an IMessageWrapper implementor. +        :type MessageClass: type +        :param raw_msg: a string containing the raw email message. +        :type raw_msg: str +        :rtype: MessageClass instance. +        """ +        assert(MessageClass is not None) +        fdoc, hdoc, cdocs = _split_into_parts(raw_msg) +        return self.get_msg_from_docs( +            MessageClass, fdoc, hdoc, cdocs) + +    def get_msg_from_docs(self, MessageClass, fdoc, hdoc, cdocs=None): +        """ +        Get an instance of a MessageClass initialized with a MessageWrapper +        that contains the passed part documents. + +        This is not the recommended way of obtaining a message, unless you know +        how to take care of ensuring the internal consistency between the part +        documents, or unless you are glueing together the part documents that +        have been previously generated by `get_msg_from_string`. + +        :param MessageClass: any Message class that can be initialized passing +                             an instance of an IMessageWrapper implementor. +        :type MessageClass: type +        :param fdoc: a dictionary containing values from which a +                     FlagsDocWrapper can be initialized +        :type fdoc: dict +        :param hdoc: a dictionary containing values from which a +                     HeaderDocWrapper can be initialized +        :type hdoc: dict +        :param cdocs: None, or a dictionary mapping integers (1-indexed) to +                      dicts from where a ContentDocWrapper can be initialized. +        :type cdocs: dict, or None + +        :rtype: MessageClass instance. +        """ +        assert(MessageClass is not None) +        return MessageClass(MessageWrapper(fdoc, hdoc, cdocs)) + +    def create_msg(self, store, msg): +        """ +        :param store: an instance of soledad, or anything that behaves alike +        :type store: +        :param msg: a Message object. + +        :return: a Deferred that is fired when all the underlying documents +                 have been created. +        :rtype: defer.Deferred +        """ +        wrapper = msg.get_wrapper() +        return wrapper.create(store) + +    def update_msg(self, store, msg): +        """ +        :param msg: a Message object. +        :param store: an instance of soledad, or anything that behaves alike +        :type store: +        :param msg: a Message object. +        :return: a Deferred that is fired when all the underlying documents +                 have been updated (actually, it's only the fdoc that's allowed +                 to update). +        :rtype: defer.Deferred +        """ +        wrapper = msg.get_wrapper() +        return wrapper.update(store) + +    # Mailbox handling + +    def get_or_create_mbox(self, store, name): +        """ +        Get the mailbox with the given name, or creatre one if it does not +        exist. + +        :param name: the name of the mailbox +        :type name: str +        """ +        index = indexes.TYPE_MBOX_IDX +        mbox = normalize_mailbox(name) +        return MailboxWrapper.get_or_create(store, index, mbox) + +    def update_mbox(self, store, mbox_wrapper): +        """ +        Update the documents for a given mailbox. +        :param mbox_wrapper: MailboxWrapper instance +        :type mbox_wrapper: MailboxWrapper +        :return: a Deferred that will be fired when the mailbox documents +                 have been updated. +        :rtype: defer.Deferred +        """ +        return mbox_wrapper.update(store) + +    def get_all_mboxes(self, store): +        """ +        Retrieve a list with wrappers for all the mailboxes. + +        :return: a deferred that will be fired with a list of all the +                 MailboxWrappers found. +        :rtype: defer.Deferred +        """ +        return MailboxWrapper.get_all(store) + + +def _split_into_parts(raw): +    # TODO signal that we can delete the original message!----- +    # when all the processing is done. +    # TODO add the linked-from info ! +    # TODO add reference to the original message? +    # TODO populate Default FLAGS/TAGS (unseen?) +    # TODO seed propely the content_docs with defaults?? + +    msg, parts, chash, size, multi = _parse_msg(raw) +    body_phash_fun = [walk.get_body_phash_simple, +                      walk.get_body_phash_multi][int(multi)] +    body_phash = body_phash_fun(walk.get_payloads(msg)) +    parts_map = walk.walk_msg_tree(parts, body_phash=body_phash) + +    fdoc = _build_flags_doc(chash, size, multi) +    hdoc = _build_headers_doc(msg, chash, parts_map) + +    # The MessageWrapper expects a dict, one-indexed +    cdocs = dict(enumerate(walk.get_raw_docs(msg, parts), 1)) + +    # XXX convert each to_dicts... +    return fdoc, hdoc, cdocs + + +def _parse_msg(raw): +    msg = message_from_string(raw) +    parts = walk.get_parts(msg) +    size = len(raw) +    chash = sha256.SHA256(raw).hexdigest() +    multi = msg.is_multipart() +    return msg, parts, chash, size, multi + + +def _build_flags_doc(chash, size, multi): +    _fdoc = FlagsDocWrapper(chash=chash, size=size, multi=multi) +    return _fdoc.serialize() + + +def _build_headers_doc(msg, chash, parts_map): +    """ +    Assemble a headers document from the original parsed message, the +    content-hash, and the parts map. + +    It takes into account possibly repeated headers. +    """ +    headers = defaultdict(list) +    for k, v in msg.items(): +        headers[k].append(v) + +    # "fix" for repeated headers. +    for k, v in headers.items(): +        newline = "\n%s: " % (k,) +        headers[k] = newline.join(v) + +    lower_headers = lowerdict(headers) +    msgid = first(_MSGID_RE.findall( +        lower_headers.get('message-id', ''))) + +    _hdoc = HeaderDocWrapper( +        chash=chash, headers=lower_headers, msgid=msgid) + +    def copy_attr(headers, key, doc): +        if key in headers: +            setattr(doc, key, headers[key]) + +    copy_attr(lower_headers, "subject", _hdoc) +    copy_attr(lower_headers, "date", _hdoc) + +    hdoc = _hdoc.serialize() +    # add parts map to header doc +    # (body, multi, part_map) +    for key in parts_map: +        hdoc[key] = parts_map[key] +    return stringify_parts_map(hdoc) diff --git a/mail/src/leap/mail/adaptors/soledad_indexes.py b/mail/src/leap/mail/adaptors/soledad_indexes.py new file mode 100644 index 00000000..f3e990dc --- /dev/null +++ b/mail/src/leap/mail/adaptors/soledad_indexes.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# soledad_indexes.py +# Copyright (C) 2013, 2014 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Soledad Indexes for Mail Documents. +""" + +# TODO +# [ ] hide most of the constants here + +# Document Type, for indexing + +TYPE = "type" +MBOX = "mbox" +FLAGS = "flags" +HEADERS = "head" +CONTENT = "cnt" +RECENT = "rct" +HDOCS_SET = "hdocset" + +INCOMING_KEY = "incoming" +ERROR_DECRYPTING_KEY = "errdecr" + +# indexing keys +CONTENT_HASH = "chash" +PAYLOAD_HASH = "phash" +MSGID = "msgid" +UID = "uid" + + +# Index  types +# -------------- + +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_MSGID_IDX = 'by-type-and-message-id' +TYPE_MBOX_SEEN_IDX = 'by-type-and-mbox-and-seen' +TYPE_MBOX_RECT_IDX = 'by-type-and-mbox-and-recent' +TYPE_MBOX_DEL_IDX = 'by-type-and-mbox-and-deleted' +TYPE_MBOX_C_HASH_IDX = 'by-type-and-mbox-and-contenthash' +TYPE_C_HASH_IDX = 'by-type-and-contenthash' +TYPE_C_HASH_PART_IDX = 'by-type-and-contenthash-and-partnumber' +TYPE_P_HASH_IDX = 'by-type-and-payloadhash' + +# Soledad index for incoming mail, without decrypting errors. +# and the backward-compatible index, will be deprecated at 0.7 +JUST_MAIL_IDX = "just-mail" +JUST_MAIL_COMPAT_IDX = "just-mail-compat" + +# Tomas created the `recent and seen index`, but the semantic is not too +# correct since the recent flag is volatile --- XXX review and delete. +#TYPE_MBOX_RECT_SEEN_IDX = 'by-type-and-mbox-and-recent-and-seen' + +# TODO +# it would be nice to measure the cost of indexing +# by many fields. + +# TODO +# make the indexes dict more readable! + +MAIL_INDEXES = { +    # generic +    TYPE_IDX: [TYPE], +    TYPE_MBOX_IDX: [TYPE, MBOX], + +    # XXX deprecate 0.4.0 +    # TYPE_MBOX_UID_IDX: [TYPE, MBOX, UID], + +    # mailboxes +    TYPE_SUBS_IDX: [TYPE, 'bool(subscribed)'], + +    # fdocs uniqueness +    TYPE_MBOX_C_HASH_IDX: [TYPE, MBOX, CONTENT_HASH], + +    # headers doc - search by msgid. +    TYPE_MSGID_IDX: [TYPE, MSGID], + +    # content, headers doc +    TYPE_C_HASH_IDX: [TYPE, CONTENT_HASH], + +    # attachment payload dedup +    TYPE_P_HASH_IDX: [TYPE, PAYLOAD_HASH], + +    # messages +    TYPE_MBOX_SEEN_IDX: [TYPE, MBOX, 'bool(seen)'], +    TYPE_MBOX_RECT_IDX: [TYPE, MBOX, 'bool(recent)'], +    TYPE_MBOX_DEL_IDX: [TYPE, MBOX, 'bool(deleted)'], +    #TYPE_MBOX_RECT_SEEN_IDX: [TYPE, MBOX, +                              #'bool(recent)', 'bool(seen)'], + +    # incoming queue +    JUST_MAIL_IDX: [INCOMING_KEY, +                    "bool(%s)" % (ERROR_DECRYPTING_KEY,)], + +    # the backward-compatible index, will be deprecated at 0.7 +    JUST_MAIL_COMPAT_IDX: [INCOMING_KEY], +} diff --git a/mail/src/leap/mail/adaptors/tests/__init__.py b/mail/src/leap/mail/adaptors/tests/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/mail/src/leap/mail/adaptors/tests/__init__.py diff --git a/mail/src/leap/mail/adaptors/tests/rfc822.message b/mail/src/leap/mail/adaptors/tests/rfc822.message new file mode 100644 index 00000000..ee97ab92 --- /dev/null +++ b/mail/src/leap/mail/adaptors/tests/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/adaptors/tests/test_models.py b/mail/src/leap/mail/adaptors/tests/test_models.py new file mode 100644 index 00000000..efe0bf2e --- /dev/null +++ b/mail/src/leap/mail/adaptors/tests/test_models.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# test_models.py +# Copyright (C) 2014 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Tests for the leap.mail.adaptors.models module. +""" +from twisted.trial import unittest + +from leap.mail.adaptors import models + + +class SerializableModelsTestCase(unittest.TestCase): + +    def test_good_serialized_model(self): + +        class M(models.SerializableModel): +            foo = 42 +            bar = 33 +            baaz_ = None +            _nope = 0 +            __nope = 0 + +            def not_today(self): +                pass + +            class IgnoreMe(object): +                pass + +            killmeplease = lambda x: x + +        serialized = M.serialize() +        expected = {'foo': 42, 'bar': 33, 'baaz': None} +        self.assertEqual(serialized, expected) + + +class DocumentWrapperTestCase(unittest.TestCase): + +    def test_wrapper_defaults(self): + +        class Wrapper(models.DocumentWrapper): +            class model(models.SerializableModel): +                foo = 42 +                bar = 11 + +        wrapper = Wrapper() +        wrapper._ignored = True +        serialized = wrapper.serialize() +        expected = {'foo': 42, 'bar': 11} +        self.assertEqual(serialized, expected) + +    def test_initialized_wrapper(self): + +        class Wrapper(models.DocumentWrapper): +            class model(models.SerializableModel): +                foo = 42 +                bar_ = 11 + +        wrapper = Wrapper(foo=0, bar=-1) +        serialized = wrapper.serialize() +        expected = {'foo': 0, 'bar': -1} +        self.assertEqual(serialized, expected) + +        wrapper.foo = 23 +        serialized = wrapper.serialize() +        expected = {'foo': 23, 'bar': -1} +        self.assertEqual(serialized, expected) + +        wrapper = Wrapper(foo=0) +        serialized = wrapper.serialize() +        expected = {'foo': 0, 'bar': 11} +        self.assertEqual(serialized, expected) + +    def test_invalid_initialized_wrapper(self): + +        class Wrapper(models.DocumentWrapper): +            class model(models.SerializableModel): +                foo = 42 +        getwrapper = lambda: Wrapper(bar=1) +        self.assertRaises(RuntimeError, getwrapper) + +    def test_no_model_wrapper(self): + +        class Wrapper(models.DocumentWrapper): +            pass + +        def getwrapper(): +            w = Wrapper() +            w.foo = None + +        self.assertRaises(RuntimeError, getwrapper) diff --git a/mail/src/leap/mail/adaptors/tests/test_soledad_adaptor.py b/mail/src/leap/mail/adaptors/tests/test_soledad_adaptor.py new file mode 100644 index 00000000..657a6029 --- /dev/null +++ b/mail/src/leap/mail/adaptors/tests/test_soledad_adaptor.py @@ -0,0 +1,583 @@ +# -*- coding: utf-8 -*- +# test_soledad_adaptor.py +# Copyright (C) 2014 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Tests for the Soledad Adaptor module - leap.mail.adaptors.soledad +""" +import os +import shutil +import tempfile + +from functools import partial + +from twisted.internet import defer +from twisted.trial import unittest + +from leap.common.testing.basetest import BaseLeapTest +from leap.mail.adaptors import models +from leap.mail.adaptors.soledad import SoledadDocumentWrapper +from leap.mail.adaptors.soledad import SoledadIndexMixin +from leap.mail.adaptors.soledad import SoledadMailAdaptor +from leap.soledad.client import Soledad + +TEST_USER = "testuser@leap.se" +TEST_PASSWD = "1234" + +# DEBUG +# import logging +# logging.basicConfig(level=logging.DEBUG) + + +def initialize_soledad(email, gnupg_home, tempdir): +    """ +    Initializes soledad by hand + +    :param email: ID for the user +    :param gnupg_home: path to home used by gnupg +    :param tempdir: path to temporal dir +    :rtype: Soledad instance +    """ + +    uuid = "foobar-uuid" +    passphrase = u"verysecretpassphrase" +    secret_path = os.path.join(tempdir, "secret.gpg") +    local_db_path = os.path.join(tempdir, "soledad.u1db") +    server_url = "https://provider" +    cert_file = "" + +    soledad = Soledad( +        uuid, +        passphrase, +        secret_path, +        local_db_path, +        server_url, +        cert_file, +        syncable=False) + +    return soledad + + +# TODO move to common module +# XXX remove duplication +class SoledadTestMixin(BaseLeapTest): +    """ +    It is **VERY** important that this base is added *AFTER* unittest.TestCase +    """ + +    def setUp(self): +        self.results = [] + +        self.old_path = os.environ['PATH'] +        self.old_home = os.environ['HOME'] +        self.tempdir = tempfile.mkdtemp(prefix="leap_tests-") +        self.home = self.tempdir +        bin_tdir = os.path.join( +            self.tempdir, +            'bin') +        os.environ["PATH"] = bin_tdir +        os.environ["HOME"] = self.tempdir + +        # Soledad: config info +        self.gnupg_home = "%s/gnupg" % self.tempdir +        self.email = 'leap@leap.se' + +        # initialize soledad by hand so we can control keys +        self._soledad = initialize_soledad( +            self.email, +            self.gnupg_home, +            self.tempdir) + +    def tearDown(self): +        """ +        tearDown method called after each test. +        """ +        self.results = [] +        try: +            self._soledad.close() +        except Exception as exc: +            print "ERROR WHILE CLOSING SOLEDAD" +            # logging.exception(exc) +        finally: +            os.environ["PATH"] = self.old_path +            os.environ["HOME"] = self.old_home +            # safety check +            assert 'leap_tests-' in self.tempdir +            shutil.rmtree(self.tempdir) + + +class CounterWrapper(SoledadDocumentWrapper): +    class model(models.SerializableModel): +        counter = 0 +        flag = None + + +class CharacterWrapper(SoledadDocumentWrapper): +    class model(models.SerializableModel): +        name = "" +        age = 20 + + +class ActorWrapper(SoledadDocumentWrapper): +    class model(models.SerializableModel): +        type_ = "actor" +        name = None + +        class __meta__(object): +            index = "name" +            list_index = ("by-type", "type_") + + +class TestAdaptor(SoledadIndexMixin): +    indexes = {'by-name': ['name'], +               'by-type-and-name': ['type', 'name'], +               'by-type': ['type']} + + +class SoledadDocWrapperTestCase(unittest.TestCase, SoledadTestMixin): +    """ +    Tests for the SoledadDocumentWrapper. +    """ +    def assert_num_docs(self, num, docs): +        self.assertEqual(len(docs[1]), num) + +    def test_create_single(self): + +        store = self._soledad +        wrapper = CounterWrapper() + +        def assert_one_doc(docs): +            self.assertEqual(docs[0], 1) + +        d = wrapper.create(store) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(assert_one_doc) +        return d + +    def test_create_many(self): + +        store = self._soledad +        w1 = CounterWrapper() +        w2 = CounterWrapper(counter=1) +        w3 = CounterWrapper(counter=2) +        w4 = CounterWrapper(counter=3) +        w5 = CounterWrapper(counter=4) + +        d1 = [w1.create(store), +              w2.create(store), +              w3.create(store), +              w4.create(store), +              w5.create(store)] + +        d = defer.gatherResults(d1) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 5)) +        return d + +    def test_multiple_updates(self): + +        store = self._soledad +        wrapper = CounterWrapper(counter=1) +        MAX = 100 + +        def assert_doc_id(doc): +            self.assertTrue(wrapper._doc_id is not None) +            return doc + +        def assert_counter_initial_ok(doc): +            self.assertEqual(wrapper.counter, 1) + +        def increment_counter(ignored): +            d1 = [] + +            def record_revision(revision): +                rev = int(revision.split(':')[1]) +                self.results.append(rev) + +            for i in list(range(MAX)): +                wrapper.counter += 1 +                wrapper.flag = i % 2 == 0 +                d = wrapper.update(store) +                d.addCallback(record_revision) +                d1.append(d) + +            return defer.gatherResults(d1) + +        def assert_counter_final_ok(doc): +            self.assertEqual(doc.content['counter'], MAX + 1) +            self.assertEqual(doc.content['flag'], False) + +        def assert_results_ordered_list(ignored): +            self.assertEqual(self.results, sorted(range(2, MAX + 2))) + +        d = wrapper.create(store) +        d.addCallback(assert_doc_id) +        d.addCallback(assert_counter_initial_ok) +        d.addCallback(increment_counter) +        d.addCallback(lambda _: store.get_doc(wrapper._doc_id)) +        d.addCallback(assert_counter_final_ok) +        d.addCallback(assert_results_ordered_list) +        return d + +    def test_delete(self): +        adaptor = TestAdaptor() +        store = self._soledad + +        wrapper_list = [] + +        def get_or_create_bob(ignored): +            def add_to_list(wrapper): +                wrapper_list.append(wrapper) +                return wrapper +            wrapper = CharacterWrapper.get_or_create( +                store, 'by-name', 'bob') +            wrapper.addCallback(add_to_list) +            return wrapper + +        def delete_bob(ignored): +            wrapper = wrapper_list[0] +            return wrapper.delete(store) + +        d = adaptor.initialize_store(store) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 0)) + +        # this should create bob document +        d.addCallback(get_or_create_bob) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 1)) + +        d.addCallback(delete_bob) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 0)) +        return d + +    def test_get_or_create(self): +        adaptor = TestAdaptor() +        store = self._soledad + +        def get_or_create_bob(ignored): +            wrapper = CharacterWrapper.get_or_create( +                store, 'by-name', 'bob') +            return wrapper + +        d = adaptor.initialize_store(store) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 0)) + +        # this should create bob document +        d.addCallback(get_or_create_bob) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 1)) + +        # this should get us bob document +        d.addCallback(get_or_create_bob) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 1)) +        return d + +    def test_get_or_create_multi_index(self): +        adaptor = TestAdaptor() +        store = self._soledad + +        def get_or_create_actor_harry(ignored): +            wrapper = ActorWrapper.get_or_create( +                store, 'by-type-and-name', 'harrison') +            return wrapper + +        def create_director_harry(ignored): +            wrapper = ActorWrapper(name="harrison", type="director") +            return wrapper.create(store) + +        d = adaptor.initialize_store(store) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 0)) + +        # this should create harrison document +        d.addCallback(get_or_create_actor_harry) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 1)) + +        # this should get us harrison document +        d.addCallback(get_or_create_actor_harry) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 1)) + +        # create director harry, should create new doc +        d.addCallback(create_director_harry) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 2)) + +        # this should get us harrison document, still 2 docs +        d.addCallback(get_or_create_actor_harry) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 2)) +        return d + +    def test_get_all(self): +        adaptor = TestAdaptor() +        store = self._soledad +        actor_names = ["harry", "carrie", "mark", "david"] + +        def create_some_actors(ignored): +            deferreds = [] +            for name in actor_names: +                dw = ActorWrapper.get_or_create( +                    store, 'by-type-and-name', name) +                deferreds.append(dw) +            return defer.gatherResults(deferreds) + +        d = adaptor.initialize_store(store) +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 0)) + +        d.addCallback(create_some_actors) + +        d.addCallback(lambda _: store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 4)) + +        def assert_actor_list_is_expected(res): +            got = set([actor.name for actor in res]) +            expected = set(actor_names) +            self.assertEqual(got, expected) + +        d.addCallback(lambda _: ActorWrapper.get_all(store)) +        d.addCallback(assert_actor_list_is_expected) +        return d + +here = os.path.split(os.path.abspath(__file__))[0] + + +class TestMessageClass(object): +    def __init__(self, wrapper): +        self.wrapper = wrapper + +    def get_wrapper(self): +        return self.wrapper + + +class SoledadMailAdaptorTestCase(unittest.TestCase, SoledadTestMixin): +    """ +    Tests for the SoledadMailAdaptor. +    """ + +    def get_adaptor(self): +        adaptor = SoledadMailAdaptor() +        adaptor.store = self._soledad +        return adaptor + +    def assert_num_docs(self, num, docs): +        self.assertEqual(len(docs[1]), num) + +    def test_mail_adaptor_init(self): +        adaptor = self.get_adaptor() +        self.assertTrue(isinstance(adaptor.indexes, dict)) +        self.assertTrue(len(adaptor.indexes) != 0) + +    # Messages + +    def test_get_msg_from_string(self): +        adaptor = self.get_adaptor() + +        with open(os.path.join(here, "rfc822.message")) as f: +            raw = f.read() + +        msg = adaptor.get_msg_from_string(TestMessageClass, raw) + +        chash = ("D27B2771C0DCCDCB468EE65A4540438" +                 "09DBD11588E87E951545BE0CBC321C308") +        phash = ("64934534C1C80E0D4FA04BE1CCBA104" +                 "F07BCA5F469C86E2C0ABE1D41310B7299") +        subject = ("[Twisted-commits] rebuild now works on " +                   "python versions from 2.2.0 and up.") +        self.assertTrue(msg.wrapper.fdoc is not None) +        self.assertTrue(msg.wrapper.hdoc is not None) +        self.assertTrue(msg.wrapper.cdocs is not None) +        self.assertEquals(len(msg.wrapper.cdocs), 1) +        self.assertEquals(msg.wrapper.fdoc.chash, chash) +        self.assertEquals(msg.wrapper.fdoc.size, 3834) +        self.assertEquals(msg.wrapper.hdoc.chash, chash) +        self.assertEqual(msg.wrapper.hdoc.headers['subject'], +                         subject) +        self.assertEqual(msg.wrapper.hdoc.subject, subject) +        self.assertEqual(msg.wrapper.cdocs[1].phash, phash) + +    def test_get_msg_from_docs(self): +        adaptor = self.get_adaptor() +        fdoc = dict( +            mbox="Foobox", +            flags=('\Seen', '\Nice'), +            tags=('Personal', 'TODO'), +            seen=False, deleted=False, +            recent=False, multi=False) +        hdoc = dict( +            subject="Test Msg") +        cdocs = { +            1: dict( +                raw='This is a test message')} + +        msg = adaptor.get_msg_from_docs( +            TestMessageClass, fdoc, hdoc, cdocs=cdocs) +        self.assertEqual(msg.wrapper.fdoc.flags, +                         ('\Seen', '\Nice')) +        self.assertEqual(msg.wrapper.fdoc.tags, +                         ('Personal', 'TODO')) +        self.assertEqual(msg.wrapper.fdoc.mbox, "Foobox") +        self.assertEqual(msg.wrapper.hdoc.multi, False) +        self.assertEqual(msg.wrapper.hdoc.subject, +                         "Test Msg") +        self.assertEqual(msg.wrapper.cdocs[1].raw, +                         "This is a test message") + +    def test_create_msg(self): +        adaptor = self.get_adaptor() + +        with open(os.path.join(here, "rfc822.message")) as f: +            raw = f.read() +        msg = adaptor.get_msg_from_string(TestMessageClass, raw) + +        def check_create_result(created): +            self.assertEqual(len(created), 3) +            for doc in created: +                self.assertTrue( +                    doc.__class__.__name__, +                    "SoledadDocument") + +        d = adaptor.create_msg(adaptor.store, msg) +        d.addCallback(check_create_result) +        return d + +    def test_update_msg(self): +        adaptor = self.get_adaptor() +        with open(os.path.join(here, "rfc822.message")) as f: +            raw = f.read() + +        def assert_msg_has_doc_id(ignored, msg): +            wrapper = msg.get_wrapper() +            self.assertTrue(wrapper.fdoc.doc_id is not None) + +        def assert_msg_has_no_flags(ignored, msg): +            wrapper = msg.get_wrapper() +            self.assertEqual(wrapper.fdoc.flags, []) + +        def update_msg_flags(ignored, msg): +            wrapper = msg.get_wrapper() +            wrapper.fdoc.flags = ["This", "That"] +            return wrapper.update(adaptor.store) + +        def assert_msg_has_flags(ignored, msg): +            wrapper = msg.get_wrapper() +            self.assertEqual(wrapper.fdoc.flags, ["This", "That"]) + +        def get_fdoc_and_check_flags(ignored): +            def assert_doc_has_flags(doc): +                self.assertEqual(doc.content['flags'], +                                 ['This', 'That']) +            wrapper = msg.get_wrapper() +            d = adaptor.store.get_doc(wrapper.fdoc.doc_id) +            d.addCallback(assert_doc_has_flags) +            return d + +        msg = adaptor.get_msg_from_string(TestMessageClass, raw) +        d = adaptor.create_msg(adaptor.store, msg) +        d.addCallback(lambda _: adaptor.store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 3)) +        d.addCallback(assert_msg_has_doc_id, msg) +        d.addCallback(assert_msg_has_no_flags, msg) + +        # update it! +        d.addCallback(update_msg_flags, msg) +        d.addCallback(assert_msg_has_flags, msg) +        d.addCallback(get_fdoc_and_check_flags) +        return d + +    # Mailboxes + +    def test_get_or_create_mbox(self): +        adaptor = self.get_adaptor() + +        def get_or_create_mbox(ignored): +            d = adaptor.get_or_create_mbox(adaptor.store, "Trash") +            return d + +        def assert_good_doc(mbox_wrapper): +            self.assertTrue(mbox_wrapper.doc_id is not None) +            self.assertEqual(mbox_wrapper.mbox, "Trash") +            self.assertEqual(mbox_wrapper.type, "mbox") +            self.assertEqual(mbox_wrapper.closed, False) +            self.assertEqual(mbox_wrapper.subscribed, False) + +        d = adaptor.initialize_store(adaptor.store) +        d.addCallback(get_or_create_mbox) +        d.addCallback(assert_good_doc) +        d.addCallback(lambda _: adaptor.store.get_all_docs()) +        d.addCallback(partial(self.assert_num_docs, 1)) +        return d + +    def test_update_mbox(self): +        adaptor = self.get_adaptor() + +        wrapper_ref = [] + +        def get_or_create_mbox(ignored): +            d = adaptor.get_or_create_mbox(adaptor.store, "Trash") +            return d + +        def update_wrapper(wrapper, wrapper_ref): +            wrapper_ref.append(wrapper) +            wrapper.subscribed = True +            wrapper.closed = True +            d = adaptor.update_mbox(adaptor.store, wrapper) +            return d + +        def get_mbox_doc_and_check_flags(res, wrapper_ref): +            wrapper = wrapper_ref[0] + +            def assert_doc_has_flags(doc): +                self.assertEqual(doc.content['subscribed'], True) +                self.assertEqual(doc.content['closed'], True) +            d = adaptor.store.get_doc(wrapper.doc_id) +            d.addCallback(assert_doc_has_flags) +            return d + +        d = adaptor.initialize_store(adaptor.store) +        d.addCallback(get_or_create_mbox) +        d.addCallback(update_wrapper, wrapper_ref) +        d.addCallback(get_mbox_doc_and_check_flags, wrapper_ref) +        return d + +    def test_get_all_mboxes(self): +        adaptor = self.get_adaptor() +        mboxes = ("Sent", "Trash", "Personal", "ListFoo") + +        def get_or_create_mboxes(ignored): +            d = [] +            for mbox in mboxes: +                d.append(adaptor.get_or_create_mbox( +                    adaptor.store, mbox)) +            return defer.gatherResults(d) + +        def get_all_mboxes(ignored): +            return adaptor.get_all_mboxes(adaptor.store) + +        def assert_mboxes_match_expected(wrappers): +            names = [m.mbox for m in wrappers] +            self.assertEqual(set(names), set(mboxes)) + +        d = adaptor.initialize_store(adaptor.store) +        d.addCallback(get_or_create_mboxes) +        d.addCallback(get_all_mboxes) +        d.addCallback(assert_mboxes_match_expected) +        return d diff --git a/mail/src/leap/mail/constants.py b/mail/src/leap/mail/constants.py new file mode 100644 index 00000000..55bf1da6 --- /dev/null +++ b/mail/src/leap/mail/constants.py @@ -0,0 +1,21 @@ +# *- coding: utf-8 -*- +# constants.py +# Copyright (C) 2014 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Constants for leap.mail. +""" + +INBOX_NAME = "INBOX" diff --git a/mail/src/leap/mail/imap/account.py b/mail/src/leap/mail/imap/account.py index fe466cb4..7dfbbd1f 100644 --- a/mail/src/leap/mail/imap/account.py +++ b/mail/src/leap/mail/imap/account.py @@ -28,10 +28,10 @@ from twisted.python import log  from zope.interface import implements  from leap.common.check import leap_assert, leap_assert_type -from leap.mail.imap.index import IndexedDB + +from leap.mail.mail import Account  from leap.mail.imap.fields import WithMsgFields -from leap.mail.imap.parser import MBoxParser -from leap.mail.imap.mailbox import SoledadMailbox +from leap.mail.imap.mailbox import SoledadMailbox, normalize_mailbox  from leap.soledad.client import Soledad  logger = logging.getLogger(__name__) @@ -39,7 +39,6 @@ logger = logging.getLogger(__name__)  PROFILE_CMD = os.environ.get('LEAP_PROFILE_IMAPCMD', False)  if PROFILE_CMD: -      def _debugProfiling(result, cmdname, start):          took = (time.time() - start) * 1000          log.msg("CMD " + cmdname + " TOOK: " + str(took) + " msec") @@ -47,96 +46,43 @@ if PROFILE_CMD:  ####################################### -# Soledad Account +# Soledad IMAP Account  ####################################### +# TODO remove MsgFields too -# TODO change name to LeapIMAPAccount, since we're using -# the memstore. -# IndexedDB should also not be here anymore. - -class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser): +class IMAPAccount(WithMsgFields):      """ -    An implementation of IAccount and INamespacePresenteer +    An implementation of an imap4 Account      that is backed by Soledad Encrypted Documents.      """      implements(imap4.IAccount, imap4.INamespacePresenter) -    _soledad = None      selected = None      closed = False -    _initialized = False -    def __init__(self, account_name, soledad, memstore=None): +    def __init__(self, user_id, store):          """ -        Creates a SoledadAccountIndex that keeps track of the mailboxes -        and subscriptions handled by this account. +        Keeps track of the mailboxes and subscriptions handled by this account. -        :param acct_name: The name of the account (user id). -        :type acct_name: str +        :param account: The name of the account (user id). +        :type account: str -        :param soledad: a Soledad instance. -        :type soledad: Soledad -        :param memstore: a MemoryStore instance. -        :type memstore: MemoryStore +        :param store: a Soledad instance. +        :type store: Soledad          """ -        leap_assert(soledad, "Need a soledad instance to initialize") -        leap_assert_type(soledad, Soledad) +        # XXX assert a generic store interface instead, so that we +        # can plug the memory store wrapper seamlessly. +        leap_assert(store, "Need a store instance to initialize") +        leap_assert_type(store, Soledad)          # XXX SHOULD assert too that the name matches the user/uuid with which          # soledad has been initialized. +        self.user_id = user_id +        self.account = Account(store) -        # XXX ??? why is this parsing mailbox name??? it's account... -        # userid? homogenize. -        self._account_name = self._parse_mailbox_name(account_name) -        self._soledad = soledad -        self._memstore = memstore - -        self.__mailboxes = set([]) - -        self._deferred_initialization = defer.Deferred() -        self._initialize_storage() - -    def _initialize_storage(self): - -        def add_mailbox_if_none(result): -            # every user should have the right to an inbox folder -            # at least, so let's make one! -            if not self.mailboxes: -                self.addMailbox(self.INBOX_NAME) - -        def finish_initialization(result): -            self._initialized = True -            self._deferred_initialization.callback(None) - -        def load_mbox_cache(result): -            d = self._load_mailboxes() -            d.addCallback(lambda _: result) -            return d - -        d = self.initialize_db() - -        d.addCallback(load_mbox_cache) -        d.addCallback(add_mailbox_if_none) -        d.addCallback(finish_initialization) - -    def callWhenReady(self, cb): -        if self._initialized: -            cb(self) -            return defer.succeed(None) -        else: -            self._deferred_initialization.addCallback(cb) -            return self._deferred_initialization - -    def _get_empty_mailbox(self): -        """ -        Returns an empty mailbox. - -        :rtype: dict -        """ -        return copy.deepcopy(self.EMPTY_MBOX) - +    # XXX should hide this in the adaptor...      def _get_mailbox_by_name(self, name):          """          Return an mbox document by name. @@ -146,32 +92,17 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :rtype: SoledadDocument          """ -        # XXX use soledadstore instead ...;          def get_first_if_any(docs):              return docs[0] if docs else None -        d = self._soledad.get_from_index( +        d = self._store.get_from_index(              self.TYPE_MBOX_IDX, self.MBOX_KEY, -            self._parse_mailbox_name(name)) +            normalize_mailbox(name))          d.addCallback(get_first_if_any)          return d -    @property -    def mailboxes(self): -        """ -        A list of the current mailboxes for this account. -        :rtype: set -        """ -        return sorted(self.__mailboxes) - -    def _load_mailboxes(self): -        def update_mailboxes(db_indexes): -            self.__mailboxes.update( -                [doc.content[self.MBOX_KEY] for doc in db_indexes]) -        d = self._soledad.get_from_index(self.TYPE_IDX, self.MBOX_KEY) -        d.addCallback(update_mailboxes) -        return d - +    # XXX move to Account? +    # XXX needed?      def getMailbox(self, name):          """          Return a Mailbox with that name, without selecting it. @@ -182,18 +113,28 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :returns: a a SoledadMailbox instance          :rtype: SoledadMailbox          """ -        name = self._parse_mailbox_name(name) +        name = normalize_mailbox(name) -        if name not in self.mailboxes: +        if name not in self.account.mailboxes:              raise imap4.MailboxException("No such mailbox: %r" % name) -        return SoledadMailbox(name, self._soledad, -                              memstore=self._memstore) +        # XXX Does mailbox really need reference to soledad? +        return SoledadMailbox(name, self._store)      #      # IAccount      # +    def _get_empty_mailbox(self): +        """ +        Returns an empty mailbox. + +        :rtype: dict +        """ +        # XXX move to mailbox module +        return copy.deepcopy(mailbox.EMPTY_MBOX) + +    # TODO use mail.Account.add_mailbox      def addMailbox(self, name, creation_ts=None):          """          Add a mailbox to the account. @@ -209,7 +150,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :returns: a Deferred that will contain the document if successful.          :rtype: bool          """ -        name = self._parse_mailbox_name(name) +        name = normalize_mailbox(name)          leap_assert(name, "Need a mailbox name to create a mailbox") @@ -232,10 +173,12 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):              d.addCallback(lambda _: result)              return d -        d = self._soledad.create_doc(mbox) +        d = self._store.create_doc(mbox)          d.addCallback(load_mbox_cache)          return d +    # TODO use mail.Account.create_mailbox? +    # Watch out, imap specific exceptions raised here.      def create(self, pathspec):          """          Create a new mailbox from the given hierarchical name. @@ -254,9 +197,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :raise MailboxException: Raised if this mailbox cannot be added.          """          # TODO raise MailboxException -        paths = filter( -            None, -            self._parse_mailbox_name(pathspec).split('/')) +        paths = filter(None, normalize_mailbox(pathspec).split('/'))          subs = []          sep = '/' @@ -295,6 +236,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):              d1.addCallback(load_mbox_cache)          return d1 +    # TODO use mail.Account.get_collection_by_mailbox      def select(self, name, readwrite=1):          """          Selects a mailbox. @@ -307,21 +249,16 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :rtype: SoledadMailbox          """ -        if PROFILE_CMD: -            start = time.time() - -        name = self._parse_mailbox_name(name) +        name = normalize_mailbox(name)          if name not in self.mailboxes:              logger.warning("No such mailbox!")              return None          self.selected = name -        sm = SoledadMailbox( -            name, self._soledad, self._memstore, readwrite) -        if PROFILE_CMD: -            _debugProfiling(None, "SELECT", start) +        sm = SoledadMailbox(name, self._store, readwrite)          return sm +    # TODO use mail.Account.delete_mailbox      def delete(self, name, force=False):          """          Deletes a mailbox. @@ -338,7 +275,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :type force: bool          :rtype: Deferred          """ -        name = self._parse_mailbox_name(name) +        name = normalize_mailbox(name)          if name not in self.mailboxes:              err = imap4.MailboxException("No such mailbox: %r" % name) @@ -369,6 +306,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          #  ??! -- can this be rite?          # self._index.removeMailbox(name) +    # TODO use mail.Account.rename_mailbox      def rename(self, oldname, newname):          """          Renames a mailbox. @@ -379,8 +317,8 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :param newname: new name of the mailbox          :type newname: str          """ -        oldname = self._parse_mailbox_name(oldname) -        newname = self._parse_mailbox_name(newname) +        oldname = normalize_mailbox(oldname) +        newname = normalize_mailbox(newname)          if oldname not in self.mailboxes:              raise imap4.NoSuchMailbox(repr(oldname)) @@ -431,6 +369,32 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):                  inferiors.append(infname)          return inferiors +    # TODO use mail.Account.list_mailboxes +    def listMailboxes(self, ref, wildcard): +        """ +        List the mailboxes. + +        from rfc 3501: +        returns a subset of names from the complete set +        of all names available to the client.  Zero or more untagged LIST +        replies are returned, containing the name attributes, hierarchy +        delimiter, and name. + +        :param ref: reference name +        :type ref: str + +        :param wildcard: mailbox name with possible wildcards +        :type wildcard: str +        """ +        # XXX use wildcard in index query +        ref = self._inferiorNames(normalize_mailbox(ref)) +        wildcard = imap4.wildcardToRegexp(wildcard, '/') +        return [(i, self.getMailbox(i)) for i in ref if wildcard.match(i)] + +    # +    # The rest of the methods are specific for leap.mail.imap.account.Account +    # +      # TODO ------------------ can we preserve the attr?      # maybe add to memory store.      def isSubscribed(self, name): @@ -442,6 +406,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :rtype: Deferred (will fire with bool)          """ +        # TODO use Flags class          subscribed = self.SUBSCRIBED_KEY          def is_subscribed(mbox): @@ -465,7 +430,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          def get_docs_content(docs):              return [doc.content[self.MBOX_KEY] for doc in docs] -        d = self._soledad.get_from_index( +        d = self._store.get_from_index(              self.TYPE_SUBS_IDX, self.MBOX_KEY, '1')          d.addCallback(get_docs_content)          return d @@ -488,7 +453,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          def update_subscribed_value(mbox):              mbox.content[subscribed] = value -            return self._soledad.put_doc(mbox) +            return self._store.put_doc(mbox)          # maybe we should store subscriptions in another          # document... @@ -508,7 +473,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :type name: str          :rtype: Deferred          """ -        name = self._parse_mailbox_name(name) +        name = normalize_mailbox(name)          def check_and_subscribe(subscriptions):              if name not in subscriptions: @@ -525,7 +490,7 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          :type name: str          :rtype: Deferred          """ -        name = self._parse_mailbox_name(name) +        name = normalize_mailbox(name)          def check_and_unsubscribe(subscriptions):              if name not in subscriptions: @@ -539,28 +504,6 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):      def getSubscriptions(self):          return self._get_subscriptions() -    def listMailboxes(self, ref, wildcard): -        """ -        List the mailboxes. - -        from rfc 3501: -        returns a subset of names from the complete set -        of all names available to the client.  Zero or more untagged LIST -        replies are returned, containing the name attributes, hierarchy -        delimiter, and name. - -        :param ref: reference name -        :type ref: str - -        :param wildcard: mailbox name with possible wildcards -        :type wildcard: str -        """ -        # XXX use wildcard in index query -        ref = self._inferiorNames( -            self._parse_mailbox_name(ref)) -        wildcard = imap4.wildcardToRegexp(wildcard, '/') -        return [(i, self.getMailbox(i)) for i in ref if wildcard.match(i)] -      #      # INamespacePresenter      # @@ -592,4 +535,4 @@ class SoledadBackedAccount(WithMsgFields, IndexedDB, MBoxParser):          """          Representation string for this object.          """ -        return "<SoledadBackedAccount (%s)>" % self._account_name +        return "<IMAPAccount (%s)>" % self.user_id diff --git a/mail/src/leap/mail/imap/fields.py b/mail/src/leap/mail/imap/fields.py index 45769391..a751c6d0 100644 --- a/mail/src/leap/mail/imap/fields.py +++ b/mail/src/leap/mail/imap/fields.py @@ -17,7 +17,9 @@  """  Fields for Mailbox and Message.  """ -from leap.mail.imap.parser import MBoxParser + +# TODO deprecate !!! (move all to constants maybe?) +# Flags -> foo  class WithMsgFields(object): @@ -25,55 +27,12 @@ class WithMsgFields(object):      Container class for class-attributes to be shared by      several message-related classes.      """ -    # indexing -    CONTENT_HASH_KEY = "chash" -    PAYLOAD_HASH_KEY = "phash" - -    # Internal representation of Message - -    # flags doc -    UID_KEY = "uid" -    MBOX_KEY = "mbox" -    SEEN_KEY = "seen" -    DEL_KEY = "deleted" -    RECENT_KEY = "recent" -    FLAGS_KEY = "flags" -    MULTIPART_KEY = "multi" -    SIZE_KEY = "size" - -    # headers -    HEADERS_KEY = "headers" -    DATE_KEY = "date" -    SUBJECT_KEY = "subject" -    PARTS_MAP_KEY = "part_map" -    BODY_KEY = "body"  # link to phash of body -    MSGID_KEY = "msgid" - -    # content -    LINKED_FROM_KEY = "lkf"  # XXX not implemented yet! -    RAW_KEY = "raw" -    CTYPE_KEY = "ctype" -      # Mailbox specific keys -    CLOSED_KEY = "closed" -    CREATED_KEY = "created" -    SUBSCRIBED_KEY = "subscribed" -    RW_KEY = "rw" -    LAST_UID_KEY = "lastuid" +    CREATED_KEY = "created"  # used??? +      RECENTFLAGS_KEY = "rct"      HDOCS_SET_KEY = "hdocset" -    # Document Type, for indexing -    TYPE_KEY = "type" -    TYPE_MBOX_VAL = "mbox" -    TYPE_FLAGS_VAL = "flags" -    TYPE_HEADERS_VAL = "head" -    TYPE_CONTENT_VAL = "cnt" -    TYPE_RECENT_VAL = "rct" -    TYPE_HDOCS_SET_VAL = "hdocset" - -    INBOX_VAL = "inbox" -      # Flags in Mailbox and Message      SEEN_FLAG = "\\Seen"      RECENT_FLAG = "\\Recent" @@ -88,86 +47,5 @@ class WithMsgFields(object):      SUBJECT_FIELD = "Subject"      DATE_FIELD = "Date" -    # Index  types -    # -------------- - -    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_MSGID_IDX = 'by-type-and-message-id' -    TYPE_MBOX_SEEN_IDX = 'by-type-and-mbox-and-seen' -    TYPE_MBOX_RECT_IDX = 'by-type-and-mbox-and-recent' -    TYPE_MBOX_DEL_IDX = 'by-type-and-mbox-and-deleted' -    TYPE_MBOX_C_HASH_IDX = 'by-type-and-mbox-and-contenthash' -    TYPE_C_HASH_IDX = 'by-type-and-contenthash' -    TYPE_C_HASH_PART_IDX = 'by-type-and-contenthash-and-partnumber' -    TYPE_P_HASH_IDX = 'by-type-and-payloadhash' - -    # Tomas created the `recent and seen index`, but the semantic is not too -    # correct since the recent flag is volatile. -    TYPE_MBOX_RECT_SEEN_IDX = 'by-type-and-mbox-and-recent-and-seen' - -    # Soledad index for incoming mail, without decrypting errors. -    JUST_MAIL_IDX = "just-mail" -    # XXX the backward-compatible index, will be deprecated at 0.7 -    JUST_MAIL_COMPAT_IDX = "just-mail-compat" - -    INCOMING_KEY = "incoming" -    ERROR_DECRYPTING_KEY = "errdecr" - -    KTYPE = TYPE_KEY -    MBOX_VAL = TYPE_MBOX_VAL -    CHASH_VAL = CONTENT_HASH_KEY -    PHASH_VAL = PAYLOAD_HASH_KEY - -    INDEXES = { -        # generic -        TYPE_IDX: [KTYPE], -        TYPE_MBOX_IDX: [KTYPE, MBOX_VAL], -        TYPE_MBOX_UID_IDX: [KTYPE, MBOX_VAL, UID_KEY], - -        # mailboxes -        TYPE_SUBS_IDX: [KTYPE, 'bool(subscribed)'], - -        # fdocs uniqueness -        TYPE_MBOX_C_HASH_IDX: [KTYPE, MBOX_VAL, CHASH_VAL], - -        # headers doc - search by msgid. -        TYPE_MSGID_IDX: [KTYPE, MSGID_KEY], - -        # content, headers doc -        TYPE_C_HASH_IDX: [KTYPE, CHASH_VAL], - -        # attachment payload dedup -        TYPE_P_HASH_IDX: [KTYPE, PHASH_VAL], - -        # messages -        TYPE_MBOX_SEEN_IDX: [KTYPE, MBOX_VAL, 'bool(seen)'], -        TYPE_MBOX_RECT_IDX: [KTYPE, MBOX_VAL, 'bool(recent)'], -        TYPE_MBOX_DEL_IDX: [KTYPE, MBOX_VAL, 'bool(deleted)'], -        TYPE_MBOX_RECT_SEEN_IDX: [KTYPE, MBOX_VAL, -                                  'bool(recent)', 'bool(seen)'], - -        # incoming queue -        JUST_MAIL_IDX: [INCOMING_KEY, -                        "bool(%s)" % (ERROR_DECRYPTING_KEY,)], - -        # the backward-compatible index, will be deprecated at 0.7 -        JUST_MAIL_COMPAT_IDX: [INCOMING_KEY], -    } - -    MBOX_KEY = MBOX_VAL - -    EMPTY_MBOX = { -        TYPE_KEY: MBOX_KEY, -        TYPE_MBOX_VAL: MBoxParser.INBOX_NAME, -        SUBJECT_KEY: "", -        FLAGS_KEY: [], -        CLOSED_KEY: False, -        SUBSCRIBED_KEY: False, -        RW_KEY: 1, -        LAST_UID_KEY: 0 -    }  fields = WithMsgFields  # alias for convenience diff --git a/mail/src/leap/mail/imap/index.py b/mail/src/leap/mail/imap/index.py deleted file mode 100644 index ea35fff7..00000000 --- a/mail/src/leap/mail/imap/index.py +++ /dev/null @@ -1,90 +0,0 @@ -# -*- coding: utf-8 -*- -# index.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/>. -""" -Index for SoledadBackedAccount, Mailbox and Messages. -""" -import logging - -from twisted.internet import defer - -from leap.common.check import leap_assert, leap_assert_type - -from leap.mail.imap.fields import fields - - -logger = logging.getLogger(__name__) - - -class IndexedDB(object): -    """ -    Methods dealing with the index. - -    This is a MixIn that needs access to the soledad instance, -    and also assumes that a INDEXES attribute is accessible to the instance. - -    INDEXES must be a dictionary of type: -    {'index-name': ['field1', 'field2']} -    """ -    # TODO we might want to move this to soledad itself, check - -    _index_creation_deferreds = [] -    index_ready = False - -    def initialize_db(self): -        """ -        Initialize the database. -        """ -        leap_assert(self._soledad, -                    "Need a soledad attribute accesible in the instance") -        leap_assert_type(self.INDEXES, dict) -        self._index_creation_deferreds = [] - -        def _on_indexes_created(ignored): -            self.index_ready = True - -        def _create_index(name, expression): -            d = self._soledad.create_index(name, *expression) -            self._index_creation_deferreds.append(d) - -        def _create_indexes(db_indexes): -            db_indexes = dict(db_indexes) -            for name, expression in fields.INDEXES.items(): -                if name not in db_indexes: -                    # The index does not yet exist. -                    _create_index(name, expression) -                    continue - -                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. -                d1 = self._soledad.delete_index(name) -                d1.addCallback(lambda _: _create_index(name, expression)) - -            all_created = defer.gatherResults(self._index_creation_deferreds) -            all_created.addCallback(_on_indexes_created) -            return all_created - -        # Ask the database for currently existing indexes. -        if not self._soledad: -            logger.debug("NO SOLEDAD ON IMAP INITIALIZATION") -            return -        if self._soledad is not None: -            d = self._soledad.list_indexes() -            d.addCallback(_create_indexes) -            return d diff --git a/mail/src/leap/mail/imap/interfaces.py b/mail/src/leap/mail/imap/interfaces.py index c906278c..f8f25fa4 100644 --- a/mail/src/leap/mail/imap/interfaces.py +++ b/mail/src/leap/mail/imap/interfaces.py @@ -20,6 +20,7 @@ Interfaces for the IMAP module.  from zope.interface import Interface, Attribute +# TODO remove  ----------------  class IMessageContainer(Interface):      """      I am a container around the different documents that a message @@ -38,6 +39,7 @@ class IMessageContainer(Interface):          """ +# TODO remove --------------------  class IMessageStore(Interface):      """      I represent a generic storage for LEAP Messages. diff --git a/mail/src/leap/mail/imap/mailbox.py b/mail/src/leap/mail/imap/mailbox.py index 3c1769a3..ea54d33c 100644 --- a/mail/src/leap/mail/imap/mailbox.py +++ b/mail/src/leap/mail/imap/mailbox.py @@ -1,6 +1,6 @@  # *- coding: utf-8 -*-  # mailbox.py -# Copyright (C) 2013 LEAP +# Copyright (C) 2013, 2014 LEAP  #  # This program is free software: you can redistribute it and/or modify  # it under the terms of the GNU General Public License as published by @@ -18,6 +18,7 @@  Soledad Mailbox.  """  import copy +import re  import threading  import logging  import StringIO @@ -27,6 +28,7 @@ import os  from collections import defaultdict  from twisted.internet import defer +from twisted.internet import reactor  from twisted.internet.task import deferLater  from twisted.python import log @@ -36,15 +38,18 @@ from zope.interface import implements  from leap.common import events as leap_events  from leap.common.events.events_pb2 import IMAP_UNREAD_MAIL  from leap.common.check import leap_assert, leap_assert_type +from leap.mail.constants import INBOX_NAME  from leap.mail.decorators import deferred_to_thread  from leap.mail.utils import empty  from leap.mail.imap.fields import WithMsgFields, fields  from leap.mail.imap.messages import MessageCollection  from leap.mail.imap.messageparts import MessageWrapper -from leap.mail.imap.parser import MBoxParser  logger = logging.getLogger(__name__) +# TODO +# [ ] Restore profile_cmd instrumentation +  """  If the environment variable `LEAP_SKIPNOTIFY` is set, we avoid  notifying clients of new messages. Use during stress tests. @@ -71,7 +76,9 @@ if PROFILE_CMD:          d.addErrback(lambda f: log.msg(f.getTraceback())) -class SoledadMailbox(WithMsgFields, MBoxParser): +# TODO Rename to Mailbox +# TODO Remove WithMsgFields +class SoledadMailbox(WithMsgFields):      """      A Soledad-backed IMAP mailbox. @@ -115,7 +122,9 @@ class SoledadMailbox(WithMsgFields, MBoxParser):      _last_uid_primed = {}      _known_uids_primed = {} -    def __init__(self, mbox, soledad, memstore, rw=1): +    # TODO pass the collection to the constructor +    # TODO pass the mbox_doc too +    def __init__(self, mbox, store, rw=1):          """          SoledadMailbox constructor. Needs to get passed a name, plus a          Soledad instance. @@ -123,30 +132,21 @@ class SoledadMailbox(WithMsgFields, MBoxParser):          :param mbox: the mailbox name          :type mbox: str -        :param soledad: a Soledad instance. -        :type soledad: Soledad - -        :param memstore: a MemoryStore instance -        :type memstore: MemoryStore +        :param store: +        :type store: Soledad          :param rw: read-and-write flag for this mailbox          :type rw: int          """          leap_assert(mbox, "Need a mailbox name to initialize") -        leap_assert(soledad, "Need a soledad instance to initialize") +        leap_assert(store, "Need a store instance to initialize") -        from twisted.internet import reactor -        self.reactor = reactor - -        self.mbox = self._parse_mailbox_name(mbox) +        self.mbox = normalize_mailbox(mbox)          self.rw = rw -        self._soledad = soledad -        self._memstore = memstore - -        self.messages = MessageCollection( -            mbox=mbox, soledad=self._soledad, memstore=self._memstore) +        self.store = store +        self.messages = MessageCollection(mbox=mbox, soledad=store)          self._uidvalidity = None          # XXX careful with this get/set (it would be @@ -214,7 +214,6 @@ class SoledadMailbox(WithMsgFields, MBoxParser):          """          return self._memstore.get_mbox_doc(self.mbox) -    # XXX the memstore->soledadstore method in memstore is not complete      def getFlags(self):          """          Returns the flags defined for this mailbox. @@ -227,7 +226,6 @@ class SoledadMailbox(WithMsgFields, MBoxParser):              flags = self.INIT_FLAGS          return map(str, flags) -    # XXX the memstore->soledadstore method in memstore is not complete      def setFlags(self, flags):          """          Sets flags for this mailbox. @@ -468,8 +466,8 @@ class SoledadMailbox(WithMsgFields, MBoxParser):          d = self._do_add_message(message, flags=flags, date=date,                                   notify_on_disk=notify_on_disk) -        if PROFILE_CMD: -            do_profile_cmd(d, "APPEND") +        #if PROFILE_CMD: +            #do_profile_cmd(d, "APPEND")          # XXX should review now that we're not using qtreactor.          # A better place for this would be  the COPY/APPEND dispatcher @@ -477,7 +475,7 @@ class SoledadMailbox(WithMsgFields, MBoxParser):          # to work fine for now.          def notifyCallback(x): -            self.reactor.callLater(0, self.notify_new) +            reactor.callLater(0, self.notify_new)              return x          d.addCallback(notifyCallback) @@ -630,9 +628,9 @@ class SoledadMailbox(WithMsgFields, MBoxParser):          :rtype: deferred          """          d = defer.Deferred() -        self.reactor.callInThread(self._do_fetch, messages_asked, uid, d) -        if PROFILE_CMD: -            do_profile_cmd(d, "FETCH") + +        # XXX do not need no thread... +        reactor.callInThread(self._do_fetch, messages_asked, uid, d)          d.addCallback(self.cb_signal_unread_to_ui)          return d @@ -800,7 +798,6 @@ class SoledadMailbox(WithMsgFields, MBoxParser):          d.addCallback(self.__cb_signal_unread_to_ui)          return result -    @deferred_to_thread      def _get_unseen_deferred(self):          return self.getUnseenCount() @@ -897,7 +894,7 @@ class SoledadMailbox(WithMsgFields, MBoxParser):          :rtype: C{list} or C{Deferred}          """          # TODO see if we can raise w/o interrupting flow -        #:raise IllegalQueryError: Raised when query is not valid. +        # :raise IllegalQueryError: Raised when query is not valid.          # example query:          #  ['UNDELETED', 'HEADER', 'Message-ID',          #   '52D44F11.9060107@dev.bitmask.net'] @@ -991,7 +988,7 @@ class SoledadMailbox(WithMsgFields, MBoxParser):          d.addCallback(createCopy)          d.addErrback(lambda f: log.msg(f.getTraceback())) -    @deferred_to_thread +    #@deferred_to_thread      def _get_msg_copy(self, message):          """          Get a copy of the fdoc for this message, and check whether @@ -1049,3 +1046,22 @@ class SoledadMailbox(WithMsgFields, MBoxParser):          """          return u"<SoledadMailbox: mbox '%s' (%s)>" % (              self.mbox, self.messages.count()) + + +def normalize_mailbox(name): +    """ +    Return a normalized representation of the mailbox ``name``. + +    This method ensures that an eventual initial 'inbox' part of a +    mailbox name is made uppercase. + +    :param name: the name of the mailbox +    :type name: unicode + +    :rtype: unicode +    """ +    _INBOX_RE = re.compile(INBOX_NAME, re.IGNORECASE) +    if _INBOX_RE.match(name): +        # ensure inital INBOX is uppercase +        return INBOX_NAME + name[len(INBOX_NAME):] +    return name diff --git a/mail/src/leap/mail/imap/messages.py b/mail/src/leap/mail/imap/messages.py index c7610910..d47c8eb0 100644 --- a/mail/src/leap/mail/imap/messages.py +++ b/mail/src/leap/mail/imap/messages.py @@ -1,6 +1,6 @@  # -*- coding: utf-8 -*-  # messages.py -# Copyright (C) 2013 LEAP +# Copyright (C) 2013, 2014 LEAP  #  # This program is free software: you can redistribute it and/or modify  # it under the terms of the GNU General Public License as published by @@ -19,30 +19,25 @@ LeapMessage and MessageCollection.  """  import copy  import logging -import re  import threading  import StringIO  from collections import defaultdict -from email import message_from_string  from functools import partial -from pycryptopp.hash import sha256  from twisted.mail import imap4 -from twisted.internet import defer, reactor +from twisted.internet import reactor  from zope.interface import implements  from zope.proxy import sameProxiedObjects  from leap.common.check import leap_assert, leap_assert_type  from leap.common.decorators import memoized_method  from leap.common.mail import get_email_charset -from leap.mail import walk -from leap.mail.utils import first, find_charset, lowerdict, empty -from leap.mail.utils import stringify_parts_map -from leap.mail.decorators import deferred_to_thread +from leap.mail.adaptors import soledad_indexes as indexes +from leap.mail.constants import INBOX_NAME +from leap.mail.utils import find_charset, empty  from leap.mail.imap.index import IndexedDB  from leap.mail.imap.fields import fields, WithMsgFields -from leap.mail.imap.memorystore import MessageWrapper  from leap.mail.imap.messageparts import MessagePart, MessagePartDoc  from leap.mail.imap.parser import MBoxParser @@ -59,9 +54,6 @@ logger = logging.getLogger(__name__)  # [ ] Delete incoming mail only after successful write!  # [ ] Remove UID from syncable db. Store only those indexes locally. -MSGID_PATTERN = r"""<([\w@.]+)>""" -MSGID_RE = re.compile(MSGID_PATTERN) -  def try_unique_query(curried):      """ @@ -90,28 +82,18 @@ def try_unique_query(curried):          logger.exception("Unhandled error %r" % exc) -""" -A dictionary that keeps one lock per mbox and uid. -""" -# XXX too much overhead? -fdoc_locks = defaultdict(lambda: defaultdict(lambda: threading.Lock())) +# FIXME remove-me +#fdoc_locks = defaultdict(lambda: defaultdict(lambda: threading.Lock())) -class LeapMessage(fields, MBoxParser): +class IMAPMessage(fields, MBoxParser):      """      The main representation of a message. - -    It indexes the messages in one mailbox by a combination -    of uid+mailbox name.      """ -    # TODO this has to change. -    # Should index primarily by chash, and keep a local-only -    # UID table. -      implements(imap4.IMessage) -    def __init__(self, soledad, uid, mbox, collection=None, container=None): +    def __init__(self, soledad, uid, mbox):          """          Initializes a LeapMessage. @@ -129,76 +111,73 @@ class LeapMessage(fields, MBoxParser):          self._soledad = soledad          self._uid = int(uid) if uid is not None else None          self._mbox = self._parse_mailbox_name(mbox) -        self._collection = collection -        self._container = container          self.__chash = None          self.__bdoc = None -    # XXX make these properties public - -    # XXX FIXME ------ the documents can be -    # deferreds too.... niice. - -    @property -    def fdoc(self): -        """ -        An accessor to the flags document. -        """ -        if all(map(bool, (self._uid, self._mbox))): -            fdoc = None -            if self._container is not None: -                fdoc = self._container.fdoc -            if not fdoc: -                fdoc = self._get_flags_doc() -            if fdoc: -                fdoc_content = fdoc.content -                self.__chash = fdoc_content.get( -                    fields.CONTENT_HASH_KEY, None) -            return fdoc - -    @property -    def hdoc(self): -        """ -        An accessor to the headers document. -        """ -        container = self._container -        if container is not None: -            hdoc = self._container.hdoc -            if hdoc and not empty(hdoc.content): -                return hdoc -        hdoc = self._get_headers_doc() - -        if container and not empty(hdoc.content): +    # TODO collection and container are deprecated. + +    # TODO move to adaptor + +    #@property +    #def fdoc(self): +        #""" +        #An accessor to the flags document. +        #""" +        #if all(map(bool, (self._uid, self._mbox))): +            #fdoc = None +            #if self._container is not None: +                #fdoc = self._container.fdoc +            #if not fdoc: +                #fdoc = self._get_flags_doc() +            #if fdoc: +                #fdoc_content = fdoc.content +                #self.__chash = fdoc_content.get( +                    #fields.CONTENT_HASH_KEY, None) +            #return fdoc +# +    #@property +    #def hdoc(self): +        #""" +        #An accessor to the headers document. +        #""" +        #container = self._container +        #if container is not None: +            #hdoc = self._container.hdoc +            #if hdoc and not empty(hdoc.content): +                #return hdoc +        #hdoc = self._get_headers_doc() +# +        #if container and not empty(hdoc.content):              # mem-cache it -            hdoc_content = hdoc.content -            chash = hdoc_content.get(fields.CONTENT_HASH_KEY) -            hdocs = {chash: hdoc_content} -            container.memstore.load_header_docs(hdocs) -        return hdoc - -    @property -    def chash(self): -        """ -        An accessor to the content hash for this message. -        """ -        if not self.fdoc: -            return None -        if not self.__chash and self.fdoc: -            self.__chash = self.fdoc.content.get( -                fields.CONTENT_HASH_KEY, None) -        return self.__chash - -    @property -    def bdoc(self): -        """ -        An accessor to the body document. -        """ -        if not self.hdoc: -            return None -        if not self.__bdoc: -            self.__bdoc = self._get_body_doc() -        return self.__bdoc +            #hdoc_content = hdoc.content +            #chash = hdoc_content.get(fields.CONTENT_HASH_KEY) +            #hdocs = {chash: hdoc_content} +            #container.memstore.load_header_docs(hdocs) +        #return hdoc +# +    #@property +    #def chash(self): +        #""" +        #An accessor to the content hash for this message. +        #""" +        #if not self.fdoc: +            #return None +        #if not self.__chash and self.fdoc: +            #self.__chash = self.fdoc.content.get( +                #fields.CONTENT_HASH_KEY, None) +        #return self.__chash + +    #@property +    #def bdoc(self): +        #""" +        #An accessor to the body document. +        #""" +        #if not self.hdoc: +            #return None +        #if not self.__bdoc: +            #self.__bdoc = self._get_body_doc() +        #return self.__bdoc      # IMessage implementation @@ -209,8 +188,13 @@ class LeapMessage(fields, MBoxParser):          :return: uid for this message          :rtype: int          """ +        # TODO ----> return lookup in local sqlcipher table.          return self._uid +    # -------------------------------------------------------------- +    # TODO -- from here on, all the methods should be proxied to the +    # instance of leap.mail.mail.Message +      def getFlags(self):          """          Retrieve the flags associated with this Message. @@ -253,25 +237,24 @@ class LeapMessage(fields, MBoxParser):          REMOVE = -1          SET = 0 -        with fdoc_locks[mbox][uid]: -            doc = self.fdoc -            if not doc: -                logger.warning( -                    "Could not find FDOC for %r:%s while setting flags!" % -                    (mbox, uid)) -                return -            current = doc.content[self.FLAGS_KEY] -            if mode == APPEND: -                newflags = tuple(set(tuple(current) + flags)) -            elif mode == REMOVE: -                newflags = tuple(set(current).difference(set(flags))) -            elif mode == SET: -                newflags = flags -            new_fdoc = { -                self.FLAGS_KEY: newflags, -                self.SEEN_KEY: self.SEEN_FLAG in newflags, -                self.DEL_KEY: self.DELETED_FLAG in newflags} -            self._collection.memstore.update_flags(mbox, uid, new_fdoc) +        doc = self.fdoc +        if not doc: +            logger.warning( +                "Could not find FDOC for %r:%s while setting flags!" % +                (mbox, uid)) +            return +        current = doc.content[self.FLAGS_KEY] +        if mode == APPEND: +            newflags = tuple(set(tuple(current) + flags)) +        elif mode == REMOVE: +            newflags = tuple(set(current).difference(set(flags))) +        elif mode == SET: +            newflags = flags +        new_fdoc = { +            self.FLAGS_KEY: newflags, +            self.SEEN_KEY: self.SEEN_FLAG in newflags, +            self.DEL_KEY: self.DELETED_FLAG in newflags} +        self._collection.memstore.update_flags(mbox, uid, new_fdoc)          return map(str, newflags) @@ -371,9 +354,9 @@ class LeapMessage(fields, MBoxParser):          else:              logger.warning("No FLAGS doc for %s:%s" % (self._mbox,                                                         self._uid)) -        if not size: +        #if not size:              # XXX fallback, should remove when all migrated. -            size = self.getBodyFile().len +            #size = self.getBodyFile().len          return size      def getHeaders(self, negate, *names): @@ -395,6 +378,9 @@ class LeapMessage(fields, MBoxParser):          # XXX refactor together with MessagePart method          headers = self._get_headers() + +        # XXX keep this in the imap imessage implementation, +        # because the server impl. expects content-type to be present.          if not headers:              logger.warning("No headers found")              return {str('content-type'): str('')} @@ -614,64 +600,23 @@ class MessageCollection(WithMsgFields, IndexedDB, MBoxParser):      (the u1db index) for all the headers documents for a given mailbox.      We use it to prefetch massively all the headers for a mailbox.      This is the second massive query, after fetching all the FLAGS,  that -    a MUA will do in a case where we do not have local disk cache. +    a typical IMAP MUA will do in a case where we do not have local disk cache.      """      HDOCS_SET_DOC = "HDOCS_SET"      templates = { -        # Message Level - -        FLAGS_DOC: { -            fields.TYPE_KEY: fields.TYPE_FLAGS_VAL, -            fields.UID_KEY: 1,  # XXX moe to a local table -            fields.MBOX_KEY: fields.INBOX_VAL, -            fields.CONTENT_HASH_KEY: "", - -            fields.SEEN_KEY: False, -            fields.DEL_KEY: False, -            fields.FLAGS_KEY: [], -            fields.MULTIPART_KEY: False, -            fields.SIZE_KEY: 0 -        }, - -        HEADERS_DOC: { -            fields.TYPE_KEY: fields.TYPE_HEADERS_VAL, -            fields.CONTENT_HASH_KEY: "", - -            fields.DATE_KEY: "", -            fields.SUBJECT_KEY: "", - -            fields.HEADERS_KEY: {}, -            fields.PARTS_MAP_KEY: {}, -        }, - -        CONTENT_DOC: { -            fields.TYPE_KEY: fields.TYPE_CONTENT_VAL, -            fields.PAYLOAD_HASH_KEY: "", -            fields.LINKED_FROM_KEY: [], -            fields.CTYPE_KEY: "",  # should index by this too - -            # should only get inmutable headers parts -            # (for indexing) -            fields.HEADERS_KEY: {}, -            fields.RAW_KEY: "", -            fields.PARTS_MAP_KEY: {}, -            fields.HEADERS_KEY: {}, -            fields.MULTIPART_KEY: False, -        }, -          # Mailbox Level          RECENT_DOC: { -            fields.TYPE_KEY: fields.TYPE_RECENT_VAL, -            fields.MBOX_KEY: fields.INBOX_VAL, +            "type": indexes.RECENT, +            "mbox": INBOX_NAME,              fields.RECENTFLAGS_KEY: [],          },          HDOCS_SET_DOC: { -            fields.TYPE_KEY: fields.TYPE_HDOCS_SET_VAL, -            fields.MBOX_KEY: fields.INBOX_VAL, +            "type": indexes.HDOCS_SET, +            "mbox": INBOX_NAME,              fields.HDOCS_SET_KEY: [],          } @@ -681,8 +626,7 @@ class MessageCollection(WithMsgFields, IndexedDB, MBoxParser):      # Different locks for wrapping both the u1db document getting/setting      # and the property getting/settting in an atomic operation. -    # TODO we would abstract this to a SoledadProperty class - +    # TODO --- deprecate ! --- use SoledadDocumentWrapper + locks      _rdoc_lock = defaultdict(lambda: threading.Lock())      _rdoc_write_lock = defaultdict(lambda: threading.Lock())      _rdoc_read_lock = defaultdict(lambda: threading.Lock()) @@ -764,81 +708,9 @@ class MessageCollection(WithMsgFields, IndexedDB, MBoxParser):                      rdoc[fields.MBOX_KEY] = self.mbox                  self._soledad.create_doc(rdoc) -    @deferred_to_thread -    def _do_parse(self, raw): -        """ -        Parse raw message and return it along with -        relevant information about its outer level. - -        This is done in a separate thread, and the callback is passed -        to `_do_add_msg` method. +    # -------------------------------------------------------------------- -        :param raw: the raw message -        :type raw: StringIO or basestring -        :return: msg, parts, chash, size, multi -        :rtype: tuple -        """ -        msg = message_from_string(raw) -        parts = walk.get_parts(msg) -        size = len(raw) -        chash = sha256.SHA256(raw).hexdigest() -        multi = msg.is_multipart() -        return msg, parts, chash, size, multi - -    def _populate_flags(self, flags, uid, chash, size, multi): -        """ -        Return a flags doc. - -        XXX Missing DOC ----------- -        """ -        fd = self._get_empty_doc(self.FLAGS_DOC) - -        fd[self.MBOX_KEY] = self.mbox -        fd[self.UID_KEY] = uid -        fd[self.CONTENT_HASH_KEY] = chash -        fd[self.SIZE_KEY] = size -        fd[self.MULTIPART_KEY] = multi -        if flags: -            fd[self.FLAGS_KEY] = flags -            fd[self.SEEN_KEY] = self.SEEN_FLAG in flags -            fd[self.DEL_KEY] = self.DELETED_FLAG in flags -            fd[self.RECENT_KEY] = True  # set always by default -        return fd - -    def _populate_headr(self, msg, chash, subject, date): -        """ -        Return a headers doc. - -        XXX Missing DOC ----------- -        """ -        headers = defaultdict(list) -        for k, v in msg.items(): -            headers[k].append(v) - -        # "fix" for repeated headers. -        for k, v in headers.items(): -            newline = "\n%s: " % (k,) -            headers[k] = newline.join(v) - -        lower_headers = lowerdict(headers) -        msgid = first(MSGID_RE.findall( -            lower_headers.get('message-id', ''))) - -        hd = self._get_empty_doc(self.HEADERS_DOC) -        hd[self.CONTENT_HASH_KEY] = chash -        hd[self.HEADERS_KEY] = headers -        hd[self.MSGID_KEY] = msgid - -        if not subject and self.SUBJECT_FIELD in headers: -            hd[self.SUBJECT_KEY] = headers[self.SUBJECT_FIELD] -        else: -            hd[self.SUBJECT_KEY] = subject - -        if not date and self.DATE_FIELD in headers: -            hd[self.DATE_KEY] = headers[self.DATE_FIELD] -        else: -            hd[self.DATE_KEY] = date -        return hd +    # -----------------------------------------------------------------------      def _fdoc_already_exists(self, chash):          """ @@ -885,86 +757,41 @@ class MessageCollection(WithMsgFields, IndexedDB, MBoxParser):              flags = tuple()          leap_assert_type(flags, tuple) -        # TODO return soledad deferred instead -        observer = defer.Deferred() -        d = self._do_parse(raw) -        d.addCallback(lambda result: reactor.callInThread( -            self._do_add_msg, result, flags, subject, date, -            notify_on_disk, observer)) -        return observer +        # TODO ---- proxy to MessageCollection addMessage + +        #observer = defer.Deferred() +        #d = self._do_parse(raw) +        #d.addCallback(lambda result: reactor.callInThread( +            #self._do_add_msg, result, flags, subject, date, +            #notify_on_disk, observer)) +        #return observer + +    # TODO --------------------------------------------------- +    # move this to leap.mail.adaptors.soledad -    # Called in thread      def _do_add_msg(self, parse_result, flags, subject,                      date, notify_on_disk, observer):          """ -        Helper that creates a new message document. -        Here lives the magic of the leap mail. Well, in soledad, really. - -        See `add_msg` docstring for parameter info. - -        :param parse_result: a tuple with the results of `self._do_parse` -        :type parse_result: tuple -        :param observer: a deferred that will be fired with the message -                         uid when the adding succeed. -        :type observer: deferred          """ -        # TODO signal that we can delete the original message!----- -        # when all the processing is done. - -        # TODO add the linked-from info ! -        # TODO add reference to the original message -          msg, parts, chash, size, multi = parse_result +        # XXX move to SoledadAdaptor write operation ... ???          # check for uniqueness --------------------------------          # Watch out! We're reserving a UID right after this!          existing_uid = self._fdoc_already_exists(chash)          if existing_uid:              msg = self.get_msg_by_uid(existing_uid) - -            # We can say the observer that we're done -            # TODO return soledad deferred instead              reactor.callFromThread(observer.callback, existing_uid)              msg.setFlags((fields.DELETED_FLAG,), -1)              return +        # TODO move UID autoincrement to MessageCollection.addMessage(mailbox)          # TODO S2 -- get FUCKING UID from autoincremental table -        uid = self.memstore.increment_last_soledad_uid(self.mbox) - -        # We can say the observer that we're done at this point, but -        # before that we should make sure it has no serious consequences -        # if we're issued, for instance, a fetch command right after... -        # reactor.callFromThread(observer.callback, uid) -        # if we did the notify, we need to invalidate the deferred -        # so not to try to fire it twice. -        # observer = None - -        fd = self._populate_flags(flags, uid, chash, size, multi) -        hd = self._populate_headr(msg, chash, subject, date) - -        body_phash_fun = [walk.get_body_phash_simple, -                          walk.get_body_phash_multi][int(multi)] -        body_phash = body_phash_fun(walk.get_payloads(msg)) -        parts_map = walk.walk_msg_tree(parts, body_phash=body_phash) - -        # add parts map to header doc -        # (body, multi, part_map) -        for key in parts_map: -            hd[key] = parts_map[key] -        del parts_map +        #uid = self.memstore.increment_last_soledad_uid(self.mbox) +        #self.set_recent_flag(uid) -        hd = stringify_parts_map(hd) -        # The MessageContainer expects a dict, one-indexed -        cdocs = dict(enumerate(walk.get_raw_docs(msg, parts), 1)) - -        self.set_recent_flag(uid) -        msg_container = MessageWrapper(fd, hd, cdocs) - -        # TODO S1 -- just pass this to memstore and return that deferred. -        self.memstore.create_message( -            self.mbox, uid, msg_container, -            observer=observer, notify_on_disk=notify_on_disk) +    # ------------------------------------------------------------      #      # getters: specific queries @@ -1073,6 +900,10 @@ class MessageCollection(WithMsgFields, IndexedDB, MBoxParser):                   the query failed.          :rtype: SoledadDocument or None.          """ +        # USED from: +        # [ ] duplicated fdoc detection +        # [ ] _get_uid_from_msgidCb +          # FIXME ----- use deferreds.          curried = partial(              self._soledad.get_from_index, @@ -1205,51 +1036,52 @@ class MessageCollection(WithMsgFields, IndexedDB, MBoxParser):          if msg_container is not None:              if mem_only: -                msg = LeapMessage(None, uid, self.mbox, collection=self, +                msg = IMAPMessage(None, uid, self.mbox, collection=self,                                    container=msg_container)              else:                  # We pass a reference to soledad just to be able to retrieve                  # missing parts that cannot be found in the container, like                  # the content docs after a copy. -                msg = LeapMessage(self._soledad, uid, self.mbox, +                msg = IMAPMessage(self._soledad, uid, self.mbox,                                    collection=self, container=msg_container)          else: -            msg = LeapMessage(self._soledad, uid, self.mbox, collection=self) +            msg = IMAPMessage(self._soledad, uid, self.mbox, collection=self)          if not msg.does_exist():              return None          return msg -    def get_all_docs(self, _type=fields.TYPE_FLAGS_VAL): -        """ -        Get all documents for the selected mailbox of the -        passed type. By default, it returns the flag docs. - -        If you want acess to the content, use __iter__ instead - -        :return: a Deferred, that will fire with a list of u1db documents -        :rtype: Deferred (promise of list of SoledadDocument) -        """ -        if _type not in fields.__dict__.values(): -            raise TypeError("Wrong type passed to get_all_docs") - +    # FIXME --- used where ? --------------------------------------------- +    #def get_all_docs(self, _type=fields.TYPE_FLAGS_VAL): +        #""" +        #Get all documents for the selected mailbox of the +        #passed type. By default, it returns the flag docs. +# +        #If you want acess to the content, use __iter__ instead +# +        #:return: a Deferred, that will fire with a list of u1db documents +        #:rtype: Deferred (promise of list of SoledadDocument) +        #""" +        #if _type not in fields.__dict__.values(): +            #raise TypeError("Wrong type passed to get_all_docs") +#          # FIXME ----- either raise or return a deferred wrapper. -        if sameProxiedObjects(self._soledad, None): -            logger.warning('Tried to get messages but soledad is None!') -            return [] - -        def get_sorted_docs(docs): -            all_docs = [doc for doc in docs] +        #if sameProxiedObjects(self._soledad, None): +            #logger.warning('Tried to get messages but soledad is None!') +            #return [] +# +        #def get_sorted_docs(docs): +            #all_docs = [doc for doc in docs]              # inneficient, but first let's grok it and then              # let's worry about efficiency.              # XXX FIXINDEX -- should implement order by in soledad              # FIXME ---------------------------------------------- -            return sorted(all_docs, key=lambda item: item.content['uid']) - -        d = self._soledad.get_from_index( -            fields.TYPE_MBOX_IDX, _type, self.mbox) -        d.addCallback(get_sorted_docs) -        return d +            #return sorted(all_docs, key=lambda item: item.content['uid']) +# +        #d = self._soledad.get_from_index( +            #fields.TYPE_MBOX_IDX, _type, self.mbox) +        #d.addCallback(get_sorted_docs) +        #return d      def all_soledad_uid_iter(self):          """ @@ -1350,7 +1182,7 @@ class MessageCollection(WithMsgFields, IndexedDB, MBoxParser):          :returns: a list of LeapMessages          :rtype: list          """ -        return [LeapMessage(self._soledad, docid, self.mbox, collection=self) +        return [IMAPMessage(self._soledad, docid, self.mbox, collection=self)                  for docid in self.unseen_iter()]      # recent messages @@ -1384,7 +1216,7 @@ class MessageCollection(WithMsgFields, IndexedDB, MBoxParser):          :returns: iterator of dicts with content for all messages.          :rtype: iterable          """ -        return (LeapMessage(self._soledad, docuid, self.mbox, collection=self) +        return (IMAPMessage(self._soledad, docuid, self.mbox, collection=self)                  for docuid in self.all_uid_iter())      def __repr__(self): diff --git a/mail/src/leap/mail/imap/parser.py b/mail/src/leap/mail/imap/parser.py deleted file mode 100644 index 4a801b0e..00000000 --- a/mail/src/leap/mail/imap/parser.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -# parser.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/>. -""" -Mail parser mixin. -""" -import re - - -class MBoxParser(object): -    """ -    Utility function to parse mailbox names. -    """ -    INBOX_NAME = "INBOX" -    INBOX_RE = re.compile(INBOX_NAME, re.IGNORECASE) - -    def _parse_mailbox_name(self, name): -        """ -        Return a normalized representation of the mailbox C{name}. - -        This method ensures that an eventual initial 'inbox' part of a -        mailbox name is made uppercase. - -        :param name: the name of the mailbox -        :type name: unicode - -        :rtype: unicode -        """ -        if self.INBOX_RE.match(name): -            # ensure inital INBOX is uppercase -            return self.INBOX_NAME + name[len(self.INBOX_NAME):] -        return name diff --git a/mail/src/leap/mail/imap/tests/test_imap.py b/mail/src/leap/mail/imap/tests/test_imap.py index dd4294c2..5af499fc 100644 --- a/mail/src/leap/mail/imap/tests/test_imap.py +++ b/mail/src/leap/mail/imap/tests/test_imap.py @@ -94,6 +94,8 @@ class MessageCollectionTestCase(IMAP4HelperMixin, unittest.TestCase):          MessageCollection interface in this particular TestCase          """          super(MessageCollectionTestCase, self).setUp() + +        # TODO deprecate memstore          memstore = MemoryStore()          self.messages = MessageCollection("testmbox%s" % (self.count,),                                            self._soledad, memstore=memstore) diff --git a/mail/src/leap/mail/imap/tests/utils.py b/mail/src/leap/mail/imap/tests/utils.py index 9a3868c5..920eeb06 100644 --- a/mail/src/leap/mail/imap/tests/utils.py +++ b/mail/src/leap/mail/imap/tests/utils.py @@ -51,6 +51,7 @@ class SimpleClient(imap4.IMAP4Client):          self.transport.loseConnection() +# XXX move to common helper  def initialize_soledad(email, gnupg_home, tempdir):      """      Initializes soledad by hand @@ -110,9 +111,7 @@ class IMAP4HelperMixin(BaseLeapTest):          """          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. +        Initializes and run a LEAP IMAP4 Server.          """          self.old_path = os.environ['PATH']          self.old_home = os.environ['HOME'] @@ -172,19 +171,17 @@ class IMAP4HelperMixin(BaseLeapTest):      def tearDown(self):          """          tearDown method called after each test. - -        Deletes all documents in the Index, and deletes -        instances of server and client.          """          try:              self._soledad.close() +        except Exception: +            print "ERROR WHILE CLOSING SOLEDAD" +        finally:              os.environ["PATH"] = self.old_path              os.environ["HOME"] = self.old_home              # safety check              assert 'leap_tests-' in self.tempdir              shutil.rmtree(self.tempdir) -        except Exception: -            print "ERROR WHILE CLOSING SOLEDAD"      def populateMessages(self):          """ @@ -223,5 +220,3 @@ class IMAP4HelperMixin(BaseLeapTest):      def loopback(self):          return loopback.loopbackAsync(self.server, self.client) - - diff --git a/mail/src/leap/mail/interfaces.py b/mail/src/leap/mail/interfaces.py new file mode 100644 index 00000000..5838ce98 --- /dev/null +++ b/mail/src/leap/mail/interfaces.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# interfaces.py +# Copyright (C) 2014 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Interfaces for the leap.mail module. +""" +from zope.interface import Interface, Attribute + + +class IMessageWrapper(Interface): +    """ +    I know how to access the different parts into which a given message is +    splitted into. +    """ + +    fdoc = Attribute('A dictionaly-like containing the flags document ' +                     '(mutable)') +    hdoc = Attribute('A dictionary-like containing the headers docuemnt ' +                     '(immutable)') +    cdocs = Attribute('A dictionary with the content-docs, one-indexed') + + +class IMailAdaptor(Interface): +    """ +    I know how to store the standard representation for messages and mailboxes, +    and how to update the relevant mutable parts when needed. +    """ + +    def initialize_store(self, store): +        """ +        Performs whatever initialization is needed before the store can be +        used (creating indexes, sanity checks, etc). + +        :param store: store +        :returns: a Deferred that will fire when the store is correctly +                  initialized. +        :rtype: deferred +        """ + +    # TODO is staticmethod valid with an interface? +    # @staticmethod +    def get_msg_from_string(self, MessageClass, raw_msg): +        """ +        Return IMessageWrapper implementor from a raw mail string + +        :param MessageClass: an implementor of IMessage +        :type raw_msg: str +        :rtype: implementor of leap.mail.IMessage +        """ + +    # TODO is staticmethod valid with an interface? +    # @staticmethod +    def get_msg_from_docs(self, MessageClass, msg_wrapper): +        """ +        Return an IMessage implementor from its parts. + +        :param MessageClass: an implementor of IMessage +        :param msg_wrapper: an implementor of IMessageWrapper +        :rtype: implementor of leap.mail.IMessage +        """ + +    # ------------------------------------------------------------------- +    # XXX unsure about the following part yet ........................... + +    # the idea behind these three methods is that the adaptor also offers a +    # fixed interface to create the documents the first time (using +    # soledad.create_docs or whatever method maps to it in a similar store, and +    # also allows to update flags and tags, hiding the actual implementation of +    # where the flags/tags live in behind the concrete MailWrapper in use +    # by this particular adaptor. In our impl it will be put_doc(fdoc) after +    # locking the getting + updating of that fdoc for atomicity. + +    # 'store' must be an instance of something that offers a minimal subset of +    # the document API that Soledad currently implements (create_doc, put_doc) +    # I *think* store should belong to Account/Collection and be passed as +    # param here instead of relying on it being an attribute of the instance. + +    def create_msg_docs(self, store, msg_wrapper): +        """ +        :param store: The documents store +        :type store: +        :param msg_wrapper: +        :type msg_wrapper: IMessageWrapper implementor +        """ + +    def update_msg_flags(self, store, msg_wrapper): +        """ +        :param store: The documents store +        :type store: +        :param msg_wrapper: +        :type msg_wrapper: IMessageWrapper implementor +        """ + +    def update_msg_tags(self, store, msg_wrapper): +        """ +        :param store: The documents store +        :type store: +        :param msg_wrapper: +        :type msg_wrapper: IMessageWrapper implementor +        """ diff --git a/mail/src/leap/mail/mail.py b/mail/src/leap/mail/mail.py new file mode 100644 index 00000000..ea9c95e8 --- /dev/null +++ b/mail/src/leap/mail/mail.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# mail.py +# Copyright (C) 2014 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Generic Access to Mail objects: Public LEAP Mail API. +""" +from twisted.internet import defer + +from leap.mail.constants import INBOX_NAME +from leap.mail.adaptors.soledad import SoledadMailAdaptor + + +# TODO +# [ ] Probably change the name of this module to "api" or "account", mail is +#     too generic (there's also IncomingMail, and OutgoingMail + + +class Message(object): + +    def __init__(self, wrapper): +        """ +        :param wrapper: an instance of an implementor of IMessageWrapper +        """ +        self._wrapper = wrapper + +    def get_wrapper(self): +        return self._wrapper + +    # imap.IMessage methods + +    def get_flags(): +        """ +        """ + +    def get_internal_date(): +        """ +        """ + +    # imap.IMessageParts + +    def get_headers(): +        """ +        """ + +    def get_body_file(): +        """ +        """ + +    def get_size(): +        """ +        """ + +    def is_multipart(): +        """ +        """ + +    def get_subpart(part): +        """ +        """ + +    # Custom methods. + +    def get_tags(): +        """ +        """ + + +class MessageCollection(object): +    """ +    A generic collection of messages. It can be messages sharing the same +    mailbox, tag, the result of a given query, or just a bunch of ids for +    master documents. + +    Since LEAP Mail is primarily oriented to store mail in Soledad, the default +    (and, so far, only) implementation of the store is contained in this +    Soledad Mail Adaptor. If you need to use a different adaptor, change the +    adaptor class attribute in your Account object. + +    Store is a reference to a particular instance of the message store (soledad +    instance or proxy, for instance). +    """ + +    # TODO look at IMessageSet methods + +    # Account should provide an adaptor instance when creating this collection. +    adaptor = None +    store = None + +    def get_message_by_doc_id(self, doc_id): +        # ... get from soledad etc +        # ... but that should be part of adaptor/store too... :/ +        fdoc, hdoc = None +        return self.adaptor.from_docs(Message, fdoc=fdoc, hdoc=hdoc) + +    # TODO review if this is the best place for: + +    def create_docs(): +        pass + +    def udpate_flags(): +        # 1. update the flags in the message wrapper --- stored where??? +        # 2. call adaptor.update_msg(store) +        pass + +    def update_tags(): +        # 1. update the tags in the message wrapper --- stored where??? +        # 2. call adaptor.update_msg(store) +        pass + +    # TODO add delete methods here? + + +class Account(object): +    """ +    Account is the top level abstraction to access collections of messages +    associated with a LEAP Mail Account. + +    It primarily handles creation and access of Mailboxes, which will be the +    basic collection handled by traditional MUAs, but it can also handle other +    types of Collections (tag based, for instance). + +    leap.mail.imap.SoledadBackedAccount partially proxies methods in this +    class. +    """ + +    # Adaptor is passed to the returned MessageCollections, so if you want to +    # use a different adaptor this is the place to change it, by subclassing +    # the Account class. + +    adaptor_class = SoledadMailAdaptor +    store = None +    mailboxes = None + +    def __init__(self, store): +        self.store = store +        self.adaptor = self.adaptor_class() + +        self.__mailboxes = set([]) +        self._initialized = False +        self._deferred_initialization = defer.Deferred() + +        self._initialize_storage() + +    def _initialize_storage(self): + +        def add_mailbox_if_none(result): +            # every user should have the right to an inbox folder +            # at least, so let's make one! +            if not self.mailboxes: +                self.add_mailbox(INBOX_NAME) + +        def finish_initialization(result): +            self._initialized = True +            self._deferred_initialization.callback(None) + +        def load_mbox_cache(result): +            d = self._load_mailboxes() +            d.addCallback(lambda _: result) +            return d + +        d = self.adaptor.initialize_store(self.store) +        d.addCallback(load_mbox_cache) +        d.addCallback(add_mailbox_if_none) +        d.addCallback(finish_initialization) + +    def callWhenReady(self, cb): +        # XXX this could use adaptor.store_ready instead...?? +        if self._initialized: +            cb(self) +            return defer.succeed(None) +        else: +            self._deferred_initialization.addCallback(cb) +            return self._deferred_initialization + +    @property +    def mailboxes(self): +        """ +        A list of the current mailboxes for this account. +        :rtype: set +        """ +        return sorted(self.__mailboxes) + +    def _load_mailboxes(self): + +        def update_mailboxes(mbox_names): +            self.__mailboxes.update(mbox_names) + +        d = self.adaptor.get_all_mboxes(self.store) +        d.addCallback(update_mailboxes) +        return d + +    # +    # Public API Starts +    # + +    # XXX params for IMAP only??? +    def list_mailboxes(self, ref, wildcard): +        self.adaptor.get_all_mboxes(self.store) + +    def add_mailbox(self, name, mbox=None): +        pass + +    def create_mailbox(self, pathspec): +        pass + +    def delete_mailbox(self, name): +        pass + +    def rename_mailbox(self, oldname, newname): +        pass + +    # FIXME yet to be decided if it belongs here... + +    def get_collection_by_mailbox(self, name): +        """ +        :rtype: MessageCollection +        """ +        # imap select will use this, passing the collection to SoledadMailbox +        # XXX pass adaptor to MessageCollection +        pass + +    def get_collection_by_docs(self, docs): +        """ +        :rtype: MessageCollection +        """ +        # get a collection of docs by a list of doc_id +        # XXX pass adaptor to MessageCollection +        pass + +    def get_collection_by_tag(self, tag): +        """ +        :rtype: MessageCollection +        """ +        # is this a good idea? +        pass | 
