diff options
-rw-r--r-- | mail/README.rst | 8 | ||||
-rw-r--r-- | mail/pkg/requirements.pip | 3 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/fetch.py | 89 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/server.py | 1605 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/service/README.rst | 39 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/service/imap-server.tac | 178 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/service/notes.txt | 81 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/service/rfc822.message | 86 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/tests/__init__.py | 15 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/tests/test_imap.py | 955 |
10 files changed, 2369 insertions, 690 deletions
diff --git a/mail/README.rst b/mail/README.rst index 92a4fa6..7224cba 100644 --- a/mail/README.rst +++ b/mail/README.rst @@ -1,5 +1,11 @@ leap.mail ========= -Mail services for the LEAP CLient. +Mail services for the LEAP Client. More info: https://leap.se + +running tests +------------- + +* nosetests --with-progressive leap.mail.imap.test_imap +* trial leap.mail.smtp diff --git a/mail/pkg/requirements.pip b/mail/pkg/requirements.pip index 5f4e7ef..d8888fd 100644 --- a/mail/pkg/requirements.pip +++ b/mail/pkg/requirements.pip @@ -1,3 +1,2 @@ -leap.common>=0.2.3-dev -leap.soledad +leap.soledad>=0.0.2-dev twisted diff --git a/mail/src/leap/mail/imap/fetch.py b/mail/src/leap/mail/imap/fetch.py new file mode 100644 index 0000000..60ae387 --- /dev/null +++ b/mail/src/leap/mail/imap/fetch.py @@ -0,0 +1,89 @@ +import json + +from twisted.python import log + +from leap.common.check import leap_assert, leap_assert_type +from leap.soledad import Soledad + +from leap.common.keymanager import openpgp + + +class LeapIncomingMail(object): + """ + Fetches mail from the incoming queue. + """ + def __init__(self, keymanager, soledad, imap_account): + + """ + Initialize LeapIMAP. + + :param keymanager: a keymanager instance + :type keymanager: keymanager.KeyManager + + :param soledad: a soledad instance + :type soledad: Soledad + + :param imap_account: the account to fetch periodically + :type imap_account: SoledadBackedAccount + """ + + leap_assert(keymanager, "need a keymanager to initialize") + leap_assert_type(soledad, Soledad) + + self._keymanager = keymanager + self._soledad = soledad + self.imapAccount = imap_account + + self._pkey = self._keymanager.get_all_keys_in_local_db( + private=True).pop() + + def fetch(self): + """ + Get new mail by syncing database, store it in the INBOX for the + user account, and remove from the incoming db. + """ + self._soledad.sync() + gen, doclist = self._soledad.get_all_docs() + #log.msg("there are %s docs" % (len(doclist),)) + + if doclist: + inbox = self.imapAccount.getMailbox('inbox') + + key = self._pkey + for doc in doclist: + keys = doc.content.keys() + if '_enc_scheme' in keys and '_enc_json' in keys: + + # XXX should check for _enc_scheme == "pubkey" || "none" + # that is what incoming mail uses. + + encdata = doc.content['_enc_json'] + decrdata = openpgp.decrypt_asym( + encdata, key, + # XXX get from public method instead + passphrase=self._soledad._passphrase) + if decrdata: + self.process_decrypted(doc, decrdata, inbox) + # XXX launch sync callback / defer + + def process_decrypted(self, doc, data, inbox): + """ + Process a successfully decrypted message + """ + log.msg("processing incoming message!") + msg = json.loads(data) + if not isinstance(msg, dict): + return False + if not msg.get('incoming', False): + return False + # ok, this is an incoming message + rawmsg = msg.get('content', None) + if not rawmsg: + return False + #log.msg("we got raw message") + + # add to inbox and delete from soledad + inbox.addMessage(rawmsg, ("\\Recent",)) + doc_id = doc.doc_id + self._soledad.delete_doc(doc) + log.msg("deleted doc %s from incoming" % doc_id) diff --git a/mail/src/leap/mail/imap/server.py b/mail/src/leap/mail/imap/server.py index 4e9c22c..30938db 100644 --- a/mail/src/leap/mail/imap/server.py +++ b/mail/src/leap/mail/imap/server.py @@ -1,253 +1,334 @@ +# -*- coding: utf-8 -*- +# server.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +""" +Soledad-backed IMAP Server. +""" import copy +import logging +import StringIO +import cStringIO +import time + +from email.parser import Parser from zope.interface import implements from twisted.mail import imap4 from twisted.internet import defer +from twisted.python import log #from twisted import cred -import u1db +#import u1db +from leap.common.check import leap_assert, leap_assert_type +from leap.soledad import Soledad +from leap.soledad.backends.sqlcipher import SQLCipherDatabase -# TODO delete this SimpleMailbox -class SimpleMailbox: - """ - A simple Mailbox for reference - We don't intend to use this, only for debugging purposes - until we stabilize unittests with SoledadMailbox - """ - implements(imap4.IMailboxInfo, imap4.IMailbox, imap4.ICloseableMailbox) +logger = logging.getLogger(__name__) - flags = ('\\Flag1', 'Flag2', '\\AnotherSysFlag', 'LastFlag') - messages = [] - mUID = 0 - rw = 1 - closed = False - def __init__(self): - self.listeners = [] - self.addListener = self.listeners.append - self.removeListener = self.listeners.remove +class MissingIndexError(Exception): + """ + Raises when tried to access a non existent index document. + """ - def getFlags(self): - return self.flags - def getUIDValidity(self): - return 42 +class BadIndexError(Exception): + """ + Raises when index is malformed or has the wrong cardinality. + """ - def getUIDNext(self): - return len(self.messages) + 1 - def getMessageCount(self): - return 9 +class WithMsgFields(object): + """ + Container class for class-attributes to be shared by + several message-related classes. + """ + # Internal representation of Message + DATE_KEY = "date" + HEADERS_KEY = "headers" + FLAGS_KEY = "flags" + MBOX_KEY = "mbox" + RAW_KEY = "raw" + SUBJECT_KEY = "subject" + UID_KEY = "uid" + + # Mailbox specific keys + CLOSED_KEY = "closed" + CREATED_KEY = "created" + SUBSCRIBED_KEY = "subscribed" + RW_KEY = "rw" + + # Document Type, for indexing + TYPE_KEY = "type" + TYPE_MESSAGE_VAL = "msg" + TYPE_MBOX_VAL = "mbox" + + INBOX_VAL = "inbox" + + # Flags for LeapDocument for indexing. + SEEN_KEY = "seen" + RECENT_KEY = "recent" + + # Flags in Mailbox and Message + SEEN_FLAG = "\\Seen" + RECENT_FLAG = "\\Recent" + ANSWERED_FLAG = "\\Answered" + FLAGGED_FLAG = "\\Flagged" # yo dawg + DELETED_FLAG = "\\Deleted" + DRAFT_FLAG = "\\Draft" + NOSELECT_FLAG = "\\Noselect" + LIST_FLAG = "List" # is this OK? (no \. ie, no system flag) + + # Fields in mail object + SUBJECT_FIELD = "Subject" + DATE_FIELD = "Date" + + +class IndexedDB(object): + """ + Methods dealing with the index. - def getRecentCount(self): - return 3 + This is a MixIn that needs access to the soledad instance, + and also assumes that a INDEXES attribute is accessible to the instance. - def getUnseenCount(self): - return 4 + INDEXES must be a dictionary of type: + {'index-name': ['field1', 'field2']} + """ + # TODO we might want to move this to soledad itself, check - def isWriteable(self): - return self.rw + 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) - def destroy(self): - pass + # Ask the database for currently existing indexes. + db_indexes = dict(self._soledad.list_indexes()) + for name, expression in SoledadBackedAccount.INDEXES.items(): + if name not in db_indexes: + # The index does not yet exist. + self._soledad.create_index(name, *expression) + continue - def getHierarchicalDelimiter(self): - return '/' + if expression == db_indexes[name]: + # The index exists and is up to date. + continue + # The index exists but the definition is not what expected, so we + # delete it and add the proper index expression. + self._soledad.delete_index(name) + self._soledad.create_index(name, *expression) - def requestStatus(self, names): - r = {} - if 'MESSAGES' in names: - r['MESSAGES'] = self.getMessageCount() - if 'RECENT' in names: - r['RECENT'] = self.getRecentCount() - if 'UIDNEXT' in names: - r['UIDNEXT'] = self.getMessageCount() + 1 - if 'UIDVALIDITY' in names: - r['UIDVALIDITY'] = self.getUID() - if 'UNSEEN' in names: - r['UNSEEN'] = self.getUnseenCount() - return defer.succeed(r) - def addMessage(self, message, flags, date=None): - self.messages.append((message, flags, date, self.mUID)) - self.mUID += 1 - return defer.succeed(None) +####################################### +# Soledad Account +####################################### - def expunge(self): - delete = [] - for i in self.messages: - if '\\Deleted' in i[1]: - delete.append(i) - for i in delete: - self.messages.remove(i) - return [i[3] for i in delete] - def close(self): - self.closed = True +class SoledadBackedAccount(WithMsgFields, IndexedDB): + """ + An implementation of IAccount and INamespacePresenteer + that is backed by Soledad Encrypted Documents. + """ + implements(imap4.IAccount, imap4.INamespacePresenter) -################################### -# SoledadAccount Index -################################### + _soledad = None + selected = None + + TYPE_IDX = 'by-type' + TYPE_MBOX_IDX = 'by-type-and-mbox' + TYPE_MBOX_UID_IDX = 'by-type-and-mbox-and-uid' + TYPE_SUBS_IDX = 'by-type-and-subscribed' + TYPE_MBOX_SEEN_IDX = 'by-type-and-mbox-and-seen' + TYPE_MBOX_RECT_IDX = 'by-type-and-mbox-and-recent' + + KTYPE = WithMsgFields.TYPE_KEY + MBOX_VAL = WithMsgFields.TYPE_MBOX_VAL + + INDEXES = { + # generic + TYPE_IDX: [KTYPE], + TYPE_MBOX_IDX: [KTYPE, MBOX_VAL], + TYPE_MBOX_UID_IDX: [KTYPE, MBOX_VAL, WithMsgFields.UID_KEY], + + # mailboxes + TYPE_SUBS_IDX: [KTYPE, 'bool(subscribed)'], + + # messages + TYPE_MBOX_SEEN_IDX: [KTYPE, MBOX_VAL, 'bool(seen)'], + TYPE_MBOX_RECT_IDX: [KTYPE, MBOX_VAL, 'bool(recent)'], + } + + INBOX_NAME = "INBOX" + MBOX_KEY = MBOX_VAL + + EMPTY_MBOX = { + WithMsgFields.TYPE_KEY: MBOX_KEY, + WithMsgFields.TYPE_MBOX_VAL: INBOX_NAME, + WithMsgFields.SUBJECT_KEY: "", + WithMsgFields.FLAGS_KEY: [], + WithMsgFields.CLOSED_KEY: False, + WithMsgFields.SUBSCRIBED_KEY: False, + WithMsgFields.RW_KEY: 1, + } + + def __init__(self, account_name, soledad=None): + """ + Creates a SoledadAccountIndex that 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 soledad: a Soledad instance. + :param soledad: Soledad + """ + leap_assert(soledad, "Need a soledad instance to initialize") + leap_assert_type(soledad, Soledad) + + # XXX SHOULD assert too that the name matches the user/uuid with which + # soledad has been initialized. + + self._account_name = account_name.upper() + self._soledad = soledad -class MissingIndexError(Exception): - """raises when tried to access a non existent index document""" + self.initialize_db() + # every user should have the right to an inbox folder + # at least, so let's make one! -class BadIndexError(Exception): - """raises when index is malformed or has the wrong cardinality""" + if not self.mailboxes: + self.addMailbox(self.INBOX_NAME) + def _get_empty_mailbox(self): + """ + Returns an empty mailbox. -EMPTY_INDEXDOC = {"is_index": True, "mailboxes": [], "subscriptions": []} -get_empty_indexdoc = lambda: copy.deepcopy(EMPTY_INDEXDOC) + :rtype: dict + """ + return copy.deepcopy(self.EMPTY_MBOX) + def _get_mailbox_by_name(self, name): + """ + Returns an mbox document by name. -class SoledadAccountIndex(object): - """ - Index for the Soledad Account - keeps track of mailboxes and subscriptions - """ - _index = None + :param name: the name of the mailbox + :type name: str - def __init__(self, soledad=None): - self._soledad = soledad - self._db = soledad._db - self._initialize_db() - - def _initialize_db(self): - """initialize the database""" - db_indexes = dict(self._soledad._db.list_indexes()) - name, expression = "isindex", ["bool(is_index)"] - if name not in db_indexes: - self._soledad._db.create_index(name, *expression) - try: - self._index = self._get_index_doc() - except MissingIndexError: - print "no index!!! creating..." - self._create_index_doc() - - def _create_index_doc(self): - """creates an empty index document""" - indexdoc = get_empty_indexdoc() - self._index = self._soledad.create_doc( - indexdoc) - - def _get_index_doc(self): - """gets index document""" - indexdoc = self._db.get_from_index("isindex", "*") - if not indexdoc: - raise MissingIndexError - if len(indexdoc) > 1: - raise BadIndexError - return indexdoc[0] - - def _update_index_doc(self): - """updates index document""" - self._db.put_doc(self._index) - - # setters and getters for the index document - - def _get_mailboxes(self): - """Get mailboxes associated with this account.""" - return self._index.content.setdefault('mailboxes', []) - - def _set_mailboxes(self, mailboxes): - """Set mailboxes associated with this account.""" - self._index.content['mailboxes'] = list(set(mailboxes)) - self._update_index_doc() - - mailboxes = property( - _get_mailboxes, _set_mailboxes, doc="Account mailboxes.") - - def _get_subscriptions(self): - """Get subscriptions associated with this account.""" - return self._index.content.setdefault('subscriptions', []) - - def _set_subscriptions(self, subscriptions): - """Set subscriptions associated with this account.""" - self._index.content['subscriptions'] = list(set(subscriptions)) - self._update_index_doc() - - subscriptions = property( - _get_subscriptions, _set_subscriptions, doc="Account subscriptions.") - - def addMailbox(self, name): - """add a mailbox to the mailboxes list.""" + :rtype: LeapDocument + """ name = name.upper() - self.mailboxes.append(name) - self._update_index_doc() + doc = self._soledad.get_from_index( + self.TYPE_MBOX_IDX, self.MBOX_KEY, name) + return doc[0] if doc else None - def removeMailbox(self, name): - """remove a mailbox from the mailboxes list.""" - self.mailboxes.remove(name) - self._update_index_doc() + @property + def mailboxes(self): + """ + A list of the current mailboxes for this account. + """ + return [str(doc.content[self.MBOX_KEY]) + for doc in self._soledad.get_from_index( + self.TYPE_IDX, self.MBOX_KEY)] - def addSubscription(self, name): - """add a subscription to the subscriptions list.""" + @property + def subscriptions(self): + """ + A list of the current subscriptions for this account. + """ + return [str(doc.content[self.MBOX_KEY]) + for doc in self._soledad.get_from_index( + self.TYPE_SUBS_IDX, self.MBOX_KEY, '1')] + + def getMailbox(self, name): + """ + Returns a Mailbox with that name, without selecting it. + + :param name: name of the mailbox + :type name: str + + :returns: a a SoledadMailbox instance + :rtype: SoledadMailbox + """ name = name.upper() - self.subscriptions.append(name) - self._update_index_doc() + if name not in self.mailboxes: + raise imap4.MailboxException("No such mailbox") - def removeSubscription(self, name): - """remove a subscription from the subscriptions list.""" - self.subscriptions.remove(name) - self._update_index_doc() + return SoledadMailbox(name, soledad=self._soledad) + ## + ## IAccount + ## -####################################### -# Soledad Account -####################################### + def addMailbox(self, name, creation_ts=None): + """ + Adds a mailbox to the account. -class SoledadBackedAccount(object): + :param name: the name of the mailbox + :type name: str - implements(imap4.IAccount, imap4.INamespacePresenter) + :param creation_ts: a optional creation timestamp to be used as + mailbox id. A timestamp will be used if no + one is provided. + :type creation_ts: int - #mailboxes = None - #subscriptions = None + :returns: True if successful + :rtype: bool + """ + name = name.upper() + # XXX should check mailbox name for RFC-compliant form - top_id = 0 # XXX move top_id to _index - _soledad = None - _db = None + if name in self.mailboxes: + raise imap4.MailboxCollision, name - def __init__(self, name, soledad=None): - self.name = name - self._soledad = soledad - self._db = soledad._db - self._index = SoledadAccountIndex(soledad=soledad) + if not creation_ts: + # by default, we pass an int value + # taken from the current time + # we make sure to take enough decimals to get a unique + # maibox-uidvalidity. + creation_ts = int(time.time() * 10E2) - #self.mailboxes = {} - #self.subscriptions = [] + mbox = self._get_empty_mailbox() + mbox[self.MBOX_KEY] = name + mbox[self.CREATED_KEY] = creation_ts - def allocateID(self): - id = self.top_id # XXX move to index !!! - self.top_id += 1 - return id + doc = self._soledad.create_doc(mbox) + return bool(doc) - @property - def mailboxes(self): - return self._index.mailboxes + def create(self, pathspec): + """ + Create a new mailbox from the given hierarchical name. - @property - def subscriptions(self): - return self._index.subscriptions + :param pathspec: The full hierarchical name of a new mailbox to create. + If any of the inferior hierarchical names to this one + do not exist, they are created as well. + :type pathspec: str - ## - ## IAccount - ## + :return: A true value if the creation succeeds. + :rtype: bool - def addMailbox(self, name, mbox=None): - name = name.upper() - if name in self.mailboxes: - raise imap4.MailboxCollision, name - if mbox is None: - mbox = self._emptyMailbox(name, self.allocateID()) - self._index.addMailbox(name) - return 1 + :raise MailboxException: Raised if this mailbox cannot be added. + """ + # TODO raise MailboxException - def create(self, pathspec): paths = filter(None, pathspec.split('/')) for accum in range(1, len(paths)): try: @@ -261,38 +342,83 @@ class SoledadBackedAccount(object): return False return True - def _emptyMailbox(self, name, id): - # XXX implement!!! - raise NotImplementedError - def select(self, name, readwrite=1): - return self.mailboxes.get(name.upper()) + """ + Selects a mailbox. + + :param name: the mailbox to select + :type name: str - def delete(self, name): + :param readwrite: 1 for readwrite permissions. + :type readwrite: int + + :rtype: bool + """ name = name.upper() - # See if this mailbox exists at all - mbox = self.mailboxes.get(name) - if not mbox: + + if name not in self.mailboxes: + return None + + self.selected = str(name) + + return SoledadMailbox( + name, rw=readwrite, + soledad=self._soledad) + + def delete(self, name, force=False): + """ + Deletes a mailbox. + + Right now it does not purge the messages, but just removes the mailbox + name from the mailboxes list!!! + + :param name: the mailbox to be deleted + :type name: str + + :param force: if True, it will not check for noselect flag or inferior + names. use with care. + :type force: bool + """ + name = name.upper() + if not name in self.mailboxes: raise imap4.MailboxException("No such mailbox") - # See if this box is flagged \Noselect - if r'\Noselect' in mbox.getFlags(): - # Check for hierarchically inferior mailboxes with this one - # as part of their root. - for others in self.mailboxes.keys(): - if others != name and others.startswith(name): - raise imap4.MailboxException, ( - "Hierarchically inferior mailboxes " - "exist and \\Noselect is set") + + mbox = self.getMailbox(name) + + if force is False: + # See if this box is flagged \Noselect + # XXX use mbox.flags instead? + if self.NOSELECT_FLAG in mbox.getFlags(): + # Check for hierarchically inferior mailboxes with this one + # as part of their root. + for others in self.mailboxes: + if others != name and others.startswith(name): + raise imap4.MailboxException, ( + "Hierarchically inferior mailboxes " + "exist and \\Noselect is set") mbox.destroy() - # iff there are no hierarchically inferior names, we will + # XXX FIXME --- not honoring the inferior names... + + # if there are no hierarchically inferior names, we will # delete it from our ken. - if self._inferiorNames(name) > 1: - del self.mailboxes[name] + #if self._inferiorNames(name) > 1: + # ??! -- can this be rite? + #self._index.removeMailbox(name) def rename(self, oldname, newname): + """ + Renames a mailbox. + + :param oldname: old name of the mailbox + :type oldname: str + + :param newname: new name of the mailbox + :type newname: str + """ oldname = oldname.upper() newname = newname.upper() + if oldname not in self.mailboxes: raise imap4.NoSuchMailbox, oldname @@ -304,34 +430,108 @@ class SoledadBackedAccount(object): raise imap4.MailboxCollision, new for (old, new) in inferiors: - self.mailboxes[new] = self.mailboxes[old] - del self.mailboxes[old] + mbox = self._get_mailbox_by_name(old) + mbox.content[self.MBOX_KEY] = new + self._soledad.put_doc(mbox) + + # XXX ---- FIXME!!!! ------------------------------------ + # until here we just renamed the index... + # We have to rename also the occurrence of this + # mailbox on ALL the messages that are contained in it!!! + # ... we maybe could use a reference to the doc_id + # in each msg, instead of the "mbox" field in msgs + # ------------------------------------------------------- def _inferiorNames(self, name): + """ + Return hierarchically inferior mailboxes. + + :param name: name of the mailbox + :rtype: list + """ + # XXX use wildcard query instead inferiors = [] - for infname in self.mailboxes.keys(): + for infname in self.mailboxes: if infname.startswith(name): inferiors.append(infname) return inferiors def isSubscribed(self, name): - return name.upper() in self.subscriptions + """ + Returns True if user is subscribed to this mailbox. + + :param name: the mailbox to be checked. + :type name: str + + :rtype: bool + """ + mbox = self._get_mailbox_by_name(name) + return mbox.content.get('subscribed', False) + + def _set_subscription(self, name, value): + """ + Sets the subscription value for a given mailbox + + :param name: the mailbox + :type name: str + + :param value: the boolean value + :type value: bool + """ + # maybe we should store subscriptions in another + # document... + if not name in self.mailboxes: + print "not this mbox" + self.addMailbox(name) + mbox = self._get_mailbox_by_name(name) + + if mbox: + mbox.content[self.SUBSCRIBED_KEY] = value + self._soledad.put_doc(mbox) def subscribe(self, name): + """ + Subscribe to this mailbox + + :param name: name of the mailbox + :type name: str + """ name = name.upper() if name not in self.subscriptions: - self._index.addSubscription(name) + self._set_subscription(name, True) def unsubscribe(self, name): + """ + Unsubscribe from this mailbox + + :param name: name of the mailbox + :type name: str + """ name = name.upper() if name not in self.subscriptions: raise imap4.MailboxException, "Not currently subscribed to " + name - self._index.removeSubscription(name) + self._set_subscription(name, False) def listMailboxes(self, ref, wildcard): + """ + List the mailboxes. + + from rfc 3501: + returns a subset of names from the complete set + of all names available to the client. Zero or more untagged LIST + replies are returned, containing the name attributes, hierarchy + delimiter, and name. + + :param ref: reference name + :type ref: str + + :param wildcard: mailbox name with possible wildcards + :type wildcard: str + """ + # XXX use wildcard in index query ref = self._inferiorNames(ref.upper()) wildcard = imap4.wildcardToRegexp(wildcard, '/') - return [(i, self.mailboxes[i]) for i in ref if wildcard.match(i)] + return [(i, self.getMailbox(i)) for i in ref if wildcard.match(i)] ## ## INamespacePresenter @@ -346,213 +546,906 @@ class SoledadBackedAccount(object): def getOtherNamespaces(self): return None + # extra, for convenience + + def deleteAllMessages(self, iknowhatiamdoing=False): + """ + Deletes all messages from all mailboxes. + Danger! high voltage! + + :param iknowhatiamdoing: confirmation parameter, needs to be True + to proceed. + """ + if iknowhatiamdoing is True: + for mbox in self.mailboxes: + self.delete(mbox, force=True) + + def __repr__(self): + """ + Representation string for this object. + """ + return "<SoledadBackedAccount (%s)>" % self._account_name + ####################################### # Soledad Message, MessageCollection # and Mailbox ####################################### -FLAGS_INDEX = 'flags' -SEEN_INDEX = 'seen' -INDEXES = {FLAGS_INDEX: ['flags'], - SEEN_INDEX: ['bool(seen)'], -} +class LeapMessage(WithMsgFields): -class Message(u1db.Document): - """A rfc822 message item.""" - # XXX TODO use email module + implements(imap4.IMessage, imap4.IMessageFile) - def _get_subject(self): - """Get the message title.""" - return self.content.get('subject') + def __init__(self, doc): + """ + Initializes a LeapMessage. - def _set_subject(self, subject): - """Set the message title.""" - self.content['subject'] = subject + :param doc: A LeapDocument containing the internal + representation of the message + :type doc: LeapDocument + """ + self._doc = doc - subject = property(_get_subject, _set_subject, - doc="Subject of the message.") + def getUID(self): + """ + Retrieve the unique identifier associated with this message - def _get_seen(self): - """Get the seen status of the message.""" - return self.content.get('seen', False) + :return: uid for this message + :rtype: int + """ + # XXX debug, to remove after a while... + if not self._doc: + log.msg('BUG!!! ---- message has no doc!') + return + return self._doc.content[self.UID_KEY] - def _set_seen(self, value): - """Set the seen status.""" - self.content['seen'] = value - - seen = property(_get_seen, _set_seen, doc="Seen flag.") - - def _get_flags(self): - """Get flags associated with the message.""" - return self.content.setdefault('flags', []) + def getFlags(self): + """ + Retrieve the flags associated with this message + + :return: The flags, represented as strings + :rtype: iterable + """ + if self._doc is None: + return [] + flags = self._doc.content.get(self.FLAGS_KEY, None) + if flags: + flags = map(str, flags) + return flags + + # setFlags, addFlags, removeFlags are not in the interface spec + # but we use them with store command. + + def setFlags(self, flags): + """ + Sets the flags for this message + + Returns a LeapDocument that needs to be updated by the caller. + + :param flags: the flags to update in the message. + :type flags: sequence of str + + :return: a LeapDocument instance + :rtype: LeapDocument + """ + log.msg('setting flags') + doc = self._doc + doc.content[self.FLAGS_KEY] = flags + doc.content[self.SEEN_KEY] = self.SEEN_FLAG in flags + doc.content[self.RECENT_KEY] = self.RECENT_FLAG in flags + return doc + + def addFlags(self, flags): + """ + Adds flags to this message. + + Returns a LeapDocument that needs to be updated by the caller. + + :param flags: the flags to add to the message. + :type flags: sequence of str + + :return: a LeapDocument instance + :rtype: LeapDocument + """ + oldflags = self.getFlags() + return self.setFlags(list(set(flags + oldflags))) + + def removeFlags(self, flags): + """ + Remove flags from this message. + + Returns a LeapDocument that needs to be updated by the caller. + + :param flags: the flags to be removed from the message. + :type flags: sequence of str + + :return: a LeapDocument instance + :rtype: LeapDocument + """ + oldflags = self.getFlags() + return self.setFlags(list(set(oldflags) - set(flags))) + + def getInternalDate(self): + """ + Retrieve the date internally associated with this message + + @rtype: C{str} + @retur: An RFC822-formatted date string. + """ + return str(self._doc.content.get(self.DATE_KEY, '')) + + # + # IMessageFile + # - def _set_flags(self, flags): - """Set flags associated with the message.""" - self.content['flags'] = list(set(flags)) + """ + Optional message interface for representing messages as files. - flags = property(_get_flags, _set_flags, doc="Message flags.") + If provided by message objects, this interface will be used instead + the more complex MIME-based interface. + """ -EMPTY_MSG = { - "subject": "", - "seen": False, - "flags": [], - "mailbox": "", -} -get_empty_msg = lambda: copy.deepcopy(EMPTY_MSG) + def open(self): + """ + Return an file-like object opened for reading. + + Reading from the returned file will return all the bytes + of which this message consists. + + :return: file-like object opened fore reading. + :rtype: StringIO + """ + fd = cStringIO.StringIO() + fd.write(str(self._doc.content.get(self.RAW_KEY, ''))) + fd.seek(0) + return fd + + # + # IMessagePart + # + + # XXX should implement the rest of IMessagePart interface: + # (and do not use the open above) + + def getBodyFile(self): + """ + Retrieve a file object containing only the body of this message. + + :return: file-like object opened for reading + :rtype: StringIO + """ + fd = StringIO.StringIO() + fd.write(str(self._doc.content.get(self.RAW_KEY, ''))) + # SHOULD use a separate BODY FIELD ... + fd.seek(0) + return fd + + def getSize(self): + """ + Return the total size, in octets, of this message. + + :return: size of the message, in octets + :rtype: int + """ + return self.getBodyFile().len + + def _get_headers(self): + """ + Return the headers dict stored in this message document. + """ + return self._doc.content.get(self.HEADERS_KEY, {}) + + def getHeaders(self, negate, *names): + """ + Retrieve a group of message headers. + + :param names: The names of the headers to retrieve or omit. + :type names: tuple of str + + :param negate: If True, indicates that the headers listed in names + should be omitted from the return value, rather + than included. + :type negate: bool + + :return: A mapping of header field names to header field values + :rtype: dict + """ + headers = self._get_headers() + if negate: + cond = lambda key: key.upper() not in names + else: + cond = lambda key: key.upper() in names + + # unpack and filter original dict by negate-condition + filter_by_cond = [ + map(str, (key, val)) for + key, val in headers.items() + if cond(key)] + return dict(filter_by_cond) + + # --- no multipart for now + + def isMultipart(self): + return False + + def getSubPart(part): + return None -class MessageCollection(object): +class MessageCollection(WithMsgFields): """ - A collection of messages + A collection of messages, surprisingly. + + It is tied to a selected mailbox name that is passed to constructor. + Implements a filter query over the messages contained in a soledad + database. """ + # XXX this should be able to produce a MessageSet methinks + + EMPTY_MSG = { + WithMsgFields.TYPE_KEY: WithMsgFields.TYPE_MESSAGE_VAL, + WithMsgFields.UID_KEY: 1, + WithMsgFields.MBOX_KEY: WithMsgFields.INBOX_VAL, + WithMsgFields.SUBJECT_KEY: "", + WithMsgFields.DATE_KEY: "", + WithMsgFields.SEEN_KEY: False, + WithMsgFields.RECENT_KEY: True, + WithMsgFields.FLAGS_KEY: [], + WithMsgFields.HEADERS_KEY: {}, + WithMsgFields.RAW_KEY: "", + } + + def __init__(self, mbox=None, soledad=None): + """ + Constructor for MessageCollection. + + :param mbox: the name of the mailbox. It is the name + with which we filter the query over the + messages database + :type mbox: str + + :param soledad: Soledad database + :type soledad: Soledad instance + """ + # XXX pass soledad directly + + leap_assert(mbox, "Need a mailbox name to initialize") + leap_assert(mbox.strip() != "", "mbox cannot be blank space") + leap_assert(isinstance(mbox, (str, unicode)), + "mbox needs to be a string") + leap_assert(soledad, "Need a soledad instance to initialize") + leap_assert(isinstance(soledad._db, SQLCipherDatabase), + "soledad._db must be an instance of SQLCipherDatabase") + + # okay, all in order, keep going... + + self.mbox = mbox.upper() + self._soledad = soledad + #self.db = db + self._parser = Parser() - def __init__(self, mbox=None, db=None): - assert mbox - self.db = db - self.initialize_db() + def _get_empty_msg(self): + """ + Returns an empty message. - def initialize_db(self): - """Initialize the database.""" - # Ask the database for currently existing indexes. - db_indexes = dict(self.db.list_indexes()) - # Loop through the indexes we expect to find. - for name, expression in INDEXES.items(): - print 'name is', name - if name not in db_indexes: - # The index does not yet exist. - print 'creating index' - self.db.create_index(name, *expression) - continue + :return: a dict containing a default empty message + :rtype: dict + """ + return copy.deepcopy(self.EMPTY_MSG) - if expression == db_indexes[name]: - print 'expression up to date' - # The index exists and is up to date. - continue - # The index exists but the definition is not what expected, so we - # delete it and add the proper index expression. - print 'deleting index' - self.db.delete_index(name) - self.db.create_index(name, *expression) + def add_msg(self, raw, subject=None, flags=None, date=None, uid=1): + """ + Creates a new message document. + + :param raw: the raw message + :type raw: str + + :param subject: subject of the message. + :type subject: str + + :param flags: flags + :type flags: list - def add_msg(self, subject=None, flags=None): - """Create a new message document.""" + :param date: the received date for the message + :type date: str + + :param uid: the message uid for this mailbox + :type uid: int + """ if flags is None: - flags = [] - content = get_empty_msg() - if subject or flags: - content['subject'] = subject - content['flags'] = flags - # Store the document in the database. Since we did not set a document - # id, the database will store it as a new document, and generate - # a valid id. - return self.db.create_doc(content) + flags = tuple() + leap_assert_type(flags, tuple) + + def stringify(o): + if isinstance(o, (cStringIO.OutputType, StringIO.StringIO)): + return o.getvalue() + else: + return o + + content = self._get_empty_msg() + content[self.MBOX_KEY] = self.mbox + + if flags: + content[self.FLAGS_KEY] = map(stringify, flags) + content[self.SEEN_KEY] = self.SEEN_FLAG in flags + + def _get_parser_fun(o): + if isinstance(o, (cStringIO.OutputType, StringIO.StringIO)): + return self._parser.parse + if isinstance(o, (str, unicode)): + return self._parser.parsestr + + msg = _get_parser_fun(raw)(raw, True) + headers = dict(msg) + + # XXX get lower case for keys? + content[self.HEADERS_KEY] = headers + content[self.SUBJECT_KEY] = headers[self.SUBJECT_FIELD] + content[self.RAW_KEY] = stringify(raw) + + if not date: + content[self.DATE_KEY] = headers[self.DATE_FIELD] + + # ...should get a sanity check here. + content[self.UID_KEY] = uid + + return self._soledad.create_doc(content) + + def remove(self, msg): + """ + Removes a message. + + :param msg: a u1db doc containing the message + :type msg: LeapDocument + """ + self._soledad.delete_doc(msg) + + # getters + + def get_by_uid(self, uid): + """ + Retrieves a message document by UID. + + :param uid: the message uid to query by + :type uid: int + + :return: A LeapDocument instance matching the query, + or None if not found. + :rtype: LeapDocument + """ + docs = self._soledad.get_from_index( + SoledadBackedAccount.TYPE_MBOX_UID_IDX, + self.TYPE_MESSAGE_VAL, self.mbox, str(uid)) + return docs[0] if docs else None + + def get_msg_by_uid(self, uid): + """ + Retrieves a LeapMessage by UID. + + :param uid: the message uid to query by + :type uid: int + + :return: A LeapMessage instance matching the query, + or None if not found. + :rtype: LeapMessage + """ + doc = self.get_by_uid(uid) + if doc: + return LeapMessage(doc) def get_all(self): - """Get all messages""" - return self.db.get_from_index(SEEN_INDEX, "*") + """ + Get all message documents for the selected mailbox. + If you want acess to the content, use __iter__ instead + + :return: a list of u1db documents + :rtype: list of LeapDocument + """ + # XXX this should return LeapMessage instances + return self._soledad.get_from_index( + SoledadBackedAccount.TYPE_MBOX_IDX, + self.TYPE_MESSAGE_VAL, self.mbox) + + def unseen_iter(self): + """ + Get an iterator for the message docs with no `seen` flag + + :return: iterator through unseen message docs + :rtype: iterable + """ + return (doc for doc in + self._soledad.get_from_index( + SoledadBackedAccount.TYPE_MBOX_RECT_IDX, + self.TYPE_MESSAGE_VAL, self.mbox, '1')) def get_unseen(self): - """Get only unseen messages""" - return self.db.get_from_index(SEEN_INDEX, "0") + """ + Get all messages with the `Unseen` flag + + :returns: a list of LeapMessages + :rtype: list + """ + return [LeapMessage(doc) for doc in self.unseen_iter()] + + def recent_iter(self): + """ + Get an iterator for the message docs with `recent` flag. + + :return: iterator through recent message docs + :rtype: iterable + """ + return (doc for doc in + self._soledad.get_from_index( + SoledadBackedAccount.TYPE_MBOX_RECT_IDX, + self.TYPE_MESSAGE_VAL, self.mbox, '1')) + + def get_recent(self): + """ + Get all messages with the `Recent` flag. + + :returns: a list of LeapMessages + :rtype: list + """ + return [LeapMessage(doc) for doc in self.recent_iter()] def count(self): + """ + Return the count of messages for this mailbox. + + :rtype: int + """ return len(self.get_all()) + def __len__(self): + """ + Returns the number of messages on this mailbox. -class SoledadMailbox: - """ - A Soledad-backed IMAP mailbox + :rtype: int + """ + return self.count() + + def __iter__(self): + """ + Returns an iterator over all messages. + + :returns: iterator of dicts with content for all messages. + :rtype: iterable + """ + # XXX return LeapMessage instead?! (change accordingly) + return (m.content for m in self.get_all()) + + def __getitem__(self, uid): + """ + Allows indexing as a list, with msg uid as the index. + + :param uid: an integer index + :type uid: int + + :return: LeapMessage or None if not found. + :rtype: LeapMessage + """ + try: + return self.get_msg_by_uid(uid) + except IndexError: + return None + + def __repr__(self): + """ + Representation string for this object. + """ + return u"<MessageCollection: mbox '%s' (%s)>" % ( + self.mbox, self.count()) + + # XXX should implement __eq__ also + + +class SoledadMailbox(WithMsgFields): """ + A Soledad-backed IMAP mailbox. + Implements the high-level method needed for the Mailbox interfaces. + The low-level database methods are contained in MessageCollection class, + which we instantiate and make accessible in the `messages` attribute. + """ implements(imap4.IMailboxInfo, imap4.IMailbox, imap4.ICloseableMailbox) - flags = ('\\Seen', '\\Answered', '\\Flagged', - '\\Deleted', '\\Draft', '\\Recent', 'List') - - #messages = [] messages = None - mUID = 0 - rw = 1 - closed = False + _closed = False + + INIT_FLAGS = (WithMsgFields.SEEN_FLAG, WithMsgFields.ANSWERED_FLAG, + WithMsgFields.FLAGGED_FLAG, WithMsgFields.DELETED_FLAG, + WithMsgFields.DRAFT_FLAG, WithMsgFields.RECENT_FLAG, + WithMsgFields.LIST_FLAG) + flags = None + + CMD_MSG = "MESSAGES" + CMD_RECENT = "RECENT" + CMD_UIDNEXT = "UIDNEXT" + CMD_UIDVALIDITY = "UIDVALIDITY" + CMD_UNSEEN = "UNSEEN" + + def __init__(self, mbox, soledad=None, rw=1): + """ + SoledadMailbox constructor. Needs to get passed a name, plus a + Soledad instance. + + :param mbox: the mailbox name + :type mbox: str + + :param soledad: a Soledad instance. + :type soledad: Soledad + + :param rw: read-and-write flags + :type rw: int + """ + leap_assert(mbox, "Need a mailbox name to initialize") + leap_assert(soledad, "Need a soledad instance to initialize") + leap_assert(isinstance(soledad._db, SQLCipherDatabase), + "soledad._db must be an instance of SQLCipherDatabase") + + self.mbox = mbox + self.rw = rw + + self._soledad = soledad + + self.messages = MessageCollection( + mbox=mbox, soledad=soledad) + + if not self.getFlags(): + self.setFlags(self.INIT_FLAGS) - def __init__(self, mbox, soledad=None): - # XXX sanity check: - #soledad is not None and isinstance(SQLCipherDatabase, soldad._db) + # XXX what is/was this used for? -------- + # ---> mail/imap4.py +1155, + # _cbSelectWork makes use of this + # probably should implement hooks here + # using leap.common.events self.listeners = [] self.addListener = self.listeners.append self.removeListener = self.listeners.remove - self._soledad = soledad - if soledad: - self.messages = MessageCollection( - mbox=mbox, db=soledad._db) + #------------------------------------------ + + def _get_mbox(self): + """ + Returns mailbox document. + + :return: A LeapDocument containing this mailbox. + :rtype: LeapDocument + """ + query = self._soledad.get_from_index( + SoledadBackedAccount.TYPE_MBOX_IDX, + self.TYPE_MBOX_VAL, self.mbox) + if query: + return query.pop() def getFlags(self): - return self.messages.db.get_index_keys(FLAGS_INDEX) + """ + Returns the flags defined for this mailbox. + + :returns: tuple of flags for this mailbox + :rtype: tuple of str + """ + return map(str, self.INIT_FLAGS) + + # TODO -- returning hardcoded flags for now, + # no need of setting flags. + + #mbox = self._get_mbox() + #if not mbox: + #return None + #flags = mbox.content.get(self.FLAGS_KEY, []) + #return map(str, flags) + + def setFlags(self, flags): + """ + Sets flags for this mailbox. + + :param flags: a tuple with the flags + :type flags: tuple of str + """ + # TODO -- fix also getFlags + leap_assert(isinstance(flags, tuple), + "flags expected to be a tuple") + mbox = self._get_mbox() + if not mbox: + return None + mbox.content[self.FLAGS_KEY] = map(str, flags) + self._soledad.put_doc(mbox) + + # XXX SHOULD BETTER IMPLEMENT ADD_FLAG, REMOVE_FLAG. + + def _get_closed(self): + """ + Return the closed attribute for this mailbox. + + :return: True if the mailbox is closed + :rtype: bool + """ + mbox = self._get_mbox() + return mbox.content.get(self.CLOSED_KEY, False) + + def _set_closed(self, closed): + """ + Set the closed attribute for this mailbox. + + :param closed: the state to be set + :type closed: bool + """ + leap_assert(isinstance(closed, bool), "closed needs to be boolean") + mbox = self._get_mbox() + mbox.content[self.CLOSED_KEY] = closed + self._soledad.put_doc(mbox) + + closed = property( + _get_closed, _set_closed, doc="Closed attribute.") def getUIDValidity(self): - return 42 + """ + Return the unique validity identifier for this mailbox. + + :return: unique validity identifier + :rtype: int + """ + mbox = self._get_mbox() + return mbox.content.get(self.CREATED_KEY, 1) + + def getUID(self, message): + """ + Return the UID of a message in the mailbox + + .. note:: this implementation does not make much sense RIGHT NOW, + but in the future will be useful to get absolute UIDs from + message sequence numbers. + + :param message: the message uid + :type message: int + + :rtype: int + """ + msg = self.messages.get_msg_by_uid(message) + return msg.getUID() def getUIDNext(self): + """ + Return the likely UID for the next message added to this + mailbox. Currently it returns the current length incremented + by one. + + :rtype: int + """ + # XXX reimplement with proper index return self.messages.count() + 1 def getMessageCount(self): + """ + Returns the total count of messages in this mailbox. + + :rtype: int + """ return self.messages.count() def getUnseenCount(self): + """ + Returns the number of messages with the 'Unseen' flag. + + :return: count of messages flagged `unseen` + :rtype: int + """ return len(self.messages.get_unseen()) def getRecentCount(self): - # XXX - return 3 + """ + Returns the number of messages with the 'Recent' flag. + + :return: count of messages flagged `recent` + :rtype: int + """ + return len(self.messages.get_recent()) def isWriteable(self): - return self.rw + """ + Get the read/write status of the mailbox. - def destroy(self): - pass + :return: 1 if mailbox is read-writeable, 0 otherwise. + :rtype: int + """ + return self.rw def getHierarchicalDelimiter(self): + """ + Returns the character used to delimite hierarchies in mailboxes. + + :rtype: str + """ return '/' def requestStatus(self, names): + """ + Handles a status request by gathering the output of the different + status commands. + + :param names: a list of strings containing the status commands + :type names: iter + """ r = {} - if 'MESSAGES' in names: - r['MESSAGES'] = self.getMessageCount() - if 'RECENT' in names: - r['RECENT'] = self.getRecentCount() - if 'UIDNEXT' in names: - r['UIDNEXT'] = self.getMessageCount() + 1 - if 'UIDVALIDITY' in names: - r['UIDVALIDITY'] = self.getUID() - if 'UNSEEN' in names: - r['UNSEEN'] = self.getUnseenCount() + if self.CMD_MSG in names: + r[self.CMD_MSG] = self.getMessageCount() + if self.CMD_RECENT in names: + r[self.CMD_RECENT] = self.getRecentCount() + if self.CMD_UIDNEXT in names: + r[self.CMD_UIDNEXT] = self.getMessageCount() + 1 + if self.CMD_UIDVALIDITY in names: + r[self.CMD_UIDVALIDITY] = self.getUID() + if self.CMD_UNSEEN in names: + r[self.CMD_UNSEEN] = self.getUnseenCount() return defer.succeed(r) def addMessage(self, message, flags, date=None): - # self.messages.add_msg((msg, flags, date, self.mUID)) - #self.messages.append((message, flags, date, self.mUID)) - # XXX CHANGE-ME - self.messages.add_msg(subject=message, flags=flags, date=date) - self.mUID += 1 + """ + Adds a message to this mailbox. + + :param message: the raw message + :type message: str + + :param flags: flag list + :type flags: list of str + + :param date: timestamp + :type date: str + + :return: a deferred that evals to None + """ + # XXX we should treat the message as an IMessage from here + uid_next = self.getUIDNext() + flags = tuple(str(flag) for flag in flags) + + self.messages.add_msg(message, flags=flags, date=date, + uid=uid_next) return defer.succeed(None) - def deleteAllDocs(self): - """deletes all docs""" - docs = self.messages.db.get_all_docs()[1] - for doc in docs: - self.messages.db.delete_doc(doc) + # commands, do not rename methods + + def destroy(self): + """ + Called before this mailbox is permanently deleted. + + Should cleanup resources, and set the \\Noselect flag + on the mailbox. + """ + self.setFlags((self.NOSELECT_FLAG,)) + self.deleteAllDocs() + + # XXX removing the mailbox in situ for now, + # we should postpone the removal + self._soledad.delete_doc(self._get_mbox()) def expunge(self): - """deletes all messages flagged \\Deleted""" - # XXX FIXME! + """ + Remove all messages flagged \\Deleted + """ + if not self.isWriteable(): + raise imap4.ReadOnlyMailbox + delete = [] - for i in self.messages: - if '\\Deleted' in i[1]: - delete.append(i) - for i in delete: - self.messages.remove(i) - return [i[3] for i in delete] + deleted = [] + for m in self.messages.get_all(): + if self.DELETED_FLAG in m.content[self.FLAGS_KEY]: + delete.append(m) + for m in delete: + deleted.append(m.content) + self.messages.remove(m) + + # XXX should return the UIDs of the deleted messages + # more generically + return [x for x in range(len(deleted))] + + def fetch(self, messages, uid): + """ + Retrieve one or more messages in this mailbox. + + from rfc 3501: The data items to be fetched can be either a single atom + or a parenthesized list. + + :param messages: IDs of the messages to retrieve information about + :type messages: MessageSet + + :param uid: If true, the IDs are UIDs. They are message sequence IDs + otherwise. + :type uid: bool + + :rtype: A tuple of two-tuples of message sequence numbers and + LeapMessage + """ + # XXX implement sequence numbers (uid = 0) + result = [] + + if not messages.last: + messages.last = self.messages.count() + + for msg_id in messages: + msg = self.messages.get_msg_by_uid(msg_id) + if msg: + result.append((msg_id, msg)) + return tuple(result) + + def store(self, messages, flags, mode, uid): + """ + Sets the flags of one or more messages. + + :param messages: The identifiers of the messages to set the flags + :type messages: A MessageSet object with the list of messages requested + + :param flags: The flags to set, unset, or add. + :type flags: sequence of str + + :param mode: If mode is -1, these flags should be removed from the + specified messages. If mode is 1, these flags should be + added to the specified messages. If mode is 0, all + existing flags should be cleared and these flags should be + added. + :type mode: -1, 0, or 1 + + :param uid: If true, the IDs specified in the query are UIDs; + otherwise they are message sequence IDs. + :type uid: bool + + :return: A dict mapping message sequence numbers to sequences of + str representing the flags set on the message after this + operation has been performed. + :rtype: dict + + :raise ReadOnlyMailbox: Raised if this mailbox is not open for + read-write. + """ + # XXX implement also sequence (uid = 0) + + if not self.isWriteable(): + log.msg('read only mailbox!') + raise imap4.ReadOnlyMailbox + + if not messages.last: + messages.last = self.messages.count() + + result = {} + for msg_id in messages: + print "MSG ID = %s" % msg_id + msg = self.messages.get_msg_by_uid(msg_id) + if mode == 1: + self._update(msg.addFlags(flags)) + elif mode == -1: + self._update(msg.removeFlags(flags)) + elif mode == 0: + self._update(msg.setFlags(flags)) + result[msg_id] = msg.getFlags() + + return result def close(self): + """ + Expunge and mark as closed + """ + self.expunge() self.closed = True + + # convenience fun + + def deleteAllDocs(self): + """ + Deletes all docs in this mailbox + """ + docs = self.messages.get_all() + for doc in docs: + self.messages.db.delete_doc(doc) + + def _update(self, doc): + """ + Updates document in u1db database + """ + #log.msg('updating doc... %s ' % doc) + self._soledad.put_doc(doc) + + def __repr__(self): + """ + Representation string for this mailbox. + """ + return u"<SoledadMailbox: mbox '%s' (%s)>" % ( + self.mbox, self.messages.count()) diff --git a/mail/src/leap/mail/imap/service/README.rst b/mail/src/leap/mail/imap/service/README.rst new file mode 100644 index 0000000..2cca9b3 --- /dev/null +++ b/mail/src/leap/mail/imap/service/README.rst @@ -0,0 +1,39 @@ +testing the service +=================== + +Run the twisted service:: + + twistd -n -y imap-server.tac + +And use offlineimap for tests:: + + offlineimap -c LEAPofflineimapRC-tests + +minimal offlineimap configuration +--------------------------------- + +[general] +accounts = leap-local + +[Account leap-local] +localrepository = LocalLeap +remoterepository = RemoteLeap + +[Repository LocalLeap] +type = Maildir +localfolders = ~/LEAPMail/Mail + +[Repository RemoteLeap] +type = IMAP +ssl = no +remotehost = localhost +remoteport = 9930 +remoteuser = user +remotepass = pass + +debugging +--------- + +Use ngrep to obtain logs of the sequences:: + + sudo ngrep -d lo -W byline port 9930 diff --git a/mail/src/leap/mail/imap/service/imap-server.tac b/mail/src/leap/mail/imap/service/imap-server.tac new file mode 100644 index 0000000..1a4661b --- /dev/null +++ b/mail/src/leap/mail/imap/service/imap-server.tac @@ -0,0 +1,178 @@ +import ConfigParser +import os + +from xdg import BaseDirectory + +from twisted.application import internet, service +from twisted.internet.protocol import ServerFactory +from twisted.mail import imap4 +from twisted.python import log + +from leap.common.check import leap_assert, leap_assert_type +from leap.mail.imap.server import SoledadBackedAccount +from leap.mail.imap.fetch import LeapIncomingMail +from leap.soledad import Soledad + +# Some constants +# XXX Should be passed to initializer too. + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +IMAP_PORT = 9930 +# The port in which imap service will run + +INCOMING_CHECK_PERIOD = 10 +# The period between succesive checks of the incoming mail +# queue (in seconds) +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +class LeapIMAPServer(imap4.IMAP4Server): + """ + An IMAP4 Server with mailboxes backed by soledad + """ + def __init__(self, *args, **kwargs): + # pop extraneous arguments + soledad = kwargs.pop('soledad', None) + user = kwargs.pop('user', None) + leap_assert(soledad, "need a soledad instance") + leap_assert_type(soledad, Soledad) + leap_assert(user, "need a user in the initialization") + + # initialize imap server! + imap4.IMAP4Server.__init__(self, *args, **kwargs) + + # we should initialize the account here, + # but we move it to the factory so we can + # populate the test account properly (and only once + # per session) + + # theAccount = SoledadBackedAccount( + # user, soledad=soledad) + + # --------------------------------- + # XXX pre-populate acct for tests!! + # populate_test_account(theAccount) + # --------------------------------- + #self.theAccount = theAccount + + def lineReceived(self, line): + log.msg('rcv: %s' % line) + imap4.IMAP4Server.lineReceived(self, line) + + def authenticateLogin(self, username, password): + # all is allowed so far. use realm instead + return imap4.IAccount, self.theAccount, lambda: None + + +class IMAPAuthRealm(object): + """ + Dummy authentication realm. Do not use in production! + """ + theAccount = None + + def requestAvatar(self, avatarId, mind, *interfaces): + return imap4.IAccount, self.theAccount, lambda: None + + +class LeapIMAPFactory(ServerFactory): + """ + Factory for a IMAP4 server with soledad remote sync and gpg-decryption + capabilities. + """ + + def __init__(self, user, soledad): + self._user = user + self._soledad = soledad + + theAccount = SoledadBackedAccount( + user, soledad=soledad) + self.theAccount = theAccount + + def buildProtocol(self, addr): + "Return a protocol suitable for the job." + imapProtocol = LeapIMAPServer( + user=self._user, + soledad=self._soledad) + imapProtocol.theAccount = self.theAccount + imapProtocol.factory = self + return imapProtocol + + +def initialize_soledad_mailbox(user_uuid, soledad_pass, server_url, + server_pemfile, token): + """ + Initializes soledad by hand + + :param user_uuid: + :param soledad_pass: + :param server_url: + :param server_pemfile: + :param token: + + :rtype: Soledad instance + """ + + base_config = BaseDirectory.xdg_config_home + secret_path = os.path.join( + base_config, "leap", "soledad", "%s.secret" % user_uuid) + soledad_path = os.path.join( + base_config, "leap", "soledad", "%s-mailbox.db" % user_uuid) + + _soledad = Soledad( + user_uuid, + soledad_pass, + secret_path, + soledad_path, + server_url, + server_pemfile, + token) + + return _soledad + + +####################################################################### +# XXX STUBBED! We need to get this in the instantiation from the client + +config = ConfigParser.ConfigParser() +config.read([os.path.expanduser('~/.config/leap/mail/mail.conf')]) + +userID = config.get('mail', 'address') +privkey = open(os.path.expanduser('~/.config/leap/mail/privkey')).read() +nickserver_url = "" + +d = {} + +for key in ('uid', 'passphrase', 'server', 'pemfile', 'token'): + d[key] = config.get('mail', key) + +soledad = initialize_soledad_mailbox( + d['uid'], + d['passphrase'], + d['server'], + d['pemfile'], + d['token']) + +# import the private key ---- should sync it from remote! +from leap.common.keymanager.openpgp import OpenPGPScheme +opgp = OpenPGPScheme(soledad) +opgp.put_ascii_key(privkey) + +from leap.common.keymanager import KeyManager +keym = KeyManager(userID, nickserver_url, soledad, d['token']) + + +factory = LeapIMAPFactory(userID, soledad) + +application = service.Application("LEAP IMAP4 Local Service") +imapService = internet.TCPServer(IMAP_PORT, factory) +imapService.setServiceParent(application) + +fetcher = LeapIncomingMail( + keym, + soledad, + factory.theAccount) + + +internet.TimerService( + INCOMING_CHECK_PERIOD, + fetcher.fetch).setServiceParent(application) diff --git a/mail/src/leap/mail/imap/service/notes.txt b/mail/src/leap/mail/imap/service/notes.txt new file mode 100644 index 0000000..623e122 --- /dev/null +++ b/mail/src/leap/mail/imap/service/notes.txt @@ -0,0 +1,81 @@ +T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP] +* OK [CAPABILITY IMAP4rev1 IDLE NAMESPACE] Twisted IMAP4rev1 Ready. + +## +T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP] +NCLJ1 CAPABILITY. + +## +T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP] +* CAPABILITY IMAP4rev1 IDLE NAMESPACE. +NCLJ1 OK CAPABILITY completed. + +## +T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP] +NCLJ2 LOGIN user "pass". + +# +T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP] +NCLJ2 OK LOGIN succeeded. + +## +T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP] +NCLJ3 CAPABILITY. + +# +T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP] +* CAPABILITY IMAP4rev1 IDLE NAMESPACE. +NCLJ3 OK CAPABILITY completed. + +# +T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP] +NCLJ4 LIST "" "". + +## +T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP] +* LIST (\Seen \Answered \Flagged \Deleted \Draft \Recent List) "/" "INBOX". +NCLJ4 OK LIST completed. + +# +T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP] +NCLJ5 LIST "" "*". + +## +T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP] +* LIST (\Seen \Answered \Flagged \Deleted \Draft \Recent List) "/" "INBOX". +NCLJ5 OK LIST completed. + +# +T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP] +NCLJ6 SELECT INBOX. + +# +T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP] +* 0 EXISTS. +* 3 RECENT. +* FLAGS (\Seen \Answered \Flagged \Deleted \Draft \Recent List). +* OK [UIDVALIDITY 42]. +NCLJ6 OK [READ-WRITE] SELECT successful. + +# +T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP] +NCLJ7 EXAMINE INBOX. + +## +T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP] +* 0 EXISTS. +* 3 RECENT. +* FLAGS (\Seen \Answered \Flagged \Deleted \Draft \Recent List). +* OK [UIDVALIDITY 42]. +NCLJ7 OK [READ-ONLY] EXAMINE successful. + +# +T 127.0.0.1:42866 -> 127.0.0.1:9930 [AP] +NCLJ8 LOGOUT. + +## +T 127.0.0.1:9930 -> 127.0.0.1:42866 [AP] +* BYE Nice talking to you. +NCLJ8 OK LOGOUT successful. + + diff --git a/mail/src/leap/mail/imap/service/rfc822.message b/mail/src/leap/mail/imap/service/rfc822.message new file mode 100644 index 0000000..ee97ab9 --- /dev/null +++ b/mail/src/leap/mail/imap/service/rfc822.message @@ -0,0 +1,86 @@ +Return-Path: <twisted-commits-admin@twistedmatrix.com> +Delivered-To: exarkun@meson.dyndns.org +Received: from localhost [127.0.0.1] + by localhost with POP3 (fetchmail-6.2.1) + for exarkun@localhost (single-drop); Thu, 20 Mar 2003 14:50:20 -0500 (EST) +Received: from pyramid.twistedmatrix.com (adsl-64-123-27-105.dsl.austtx.swbell.net [64.123.27.105]) + by intarweb.us (Postfix) with ESMTP id 4A4A513EA4 + for <exarkun@meson.dyndns.org>; Thu, 20 Mar 2003 14:49:27 -0500 (EST) +Received: from localhost ([127.0.0.1] helo=pyramid.twistedmatrix.com) + by pyramid.twistedmatrix.com with esmtp (Exim 3.35 #1 (Debian)) + id 18w648-0007Vl-00; Thu, 20 Mar 2003 13:51:04 -0600 +Received: from acapnotic by pyramid.twistedmatrix.com with local (Exim 3.35 #1 (Debian)) + id 18w63j-0007VK-00 + for <twisted-commits@twistedmatrix.com>; Thu, 20 Mar 2003 13:50:39 -0600 +To: twisted-commits@twistedmatrix.com +From: etrepum CVS <etrepum@twistedmatrix.com> +Reply-To: twisted-python@twistedmatrix.com +X-Mailer: CVSToys +Message-Id: <E18w63j-0007VK-00@pyramid.twistedmatrix.com> +Subject: [Twisted-commits] rebuild now works on python versions from 2.2.0 and up. +Sender: twisted-commits-admin@twistedmatrix.com +Errors-To: twisted-commits-admin@twistedmatrix.com +X-BeenThere: twisted-commits@twistedmatrix.com +X-Mailman-Version: 2.0.11 +Precedence: bulk +List-Help: <mailto:twisted-commits-request@twistedmatrix.com?subject=help> +List-Post: <mailto:twisted-commits@twistedmatrix.com> +List-Subscribe: <http://twistedmatrix.com/cgi-bin/mailman/listinfo/twisted-commits>, + <mailto:twisted-commits-request@twistedmatrix.com?subject=subscribe> +List-Id: <twisted-commits.twistedmatrix.com> +List-Unsubscribe: <http://twistedmatrix.com/cgi-bin/mailman/listinfo/twisted-commits>, + <mailto:twisted-commits-request@twistedmatrix.com?subject=unsubscribe> +List-Archive: <http://twistedmatrix.com/pipermail/twisted-commits/> +Date: Thu, 20 Mar 2003 13:50:39 -0600 + +Modified files: +Twisted/twisted/python/rebuild.py 1.19 1.20 + +Log message: +rebuild now works on python versions from 2.2.0 and up. + + +ViewCVS links: +http://twistedmatrix.com/users/jh.twistd/viewcvs/cgi/viewcvs.cgi/twisted/python/rebuild.py.diff?r1=text&tr1=1.19&r2=text&tr2=1.20&cvsroot=Twisted + +Index: Twisted/twisted/python/rebuild.py +diff -u Twisted/twisted/python/rebuild.py:1.19 Twisted/twisted/python/rebuild.py:1.20 +--- Twisted/twisted/python/rebuild.py:1.19 Fri Jan 17 13:50:49 2003 ++++ Twisted/twisted/python/rebuild.py Thu Mar 20 11:50:08 2003 +@@ -206,15 +206,27 @@ + clazz.__dict__.clear() + clazz.__getattr__ = __getattr__ + clazz.__module__ = module.__name__ ++ if newclasses: ++ import gc ++ if (2, 2, 0) <= sys.version_info[:3] < (2, 2, 2): ++ hasBrokenRebuild = 1 ++ gc_objects = gc.get_objects() ++ else: ++ hasBrokenRebuild = 0 + for nclass in newclasses: + ga = getattr(module, nclass.__name__) + if ga is nclass: + log.msg("WARNING: new-class %s not replaced by reload!" % reflect.qual(nclass)) + else: +- import gc +- for r in gc.get_referrers(nclass): +- if isinstance(r, nclass): ++ if hasBrokenRebuild: ++ for r in gc_objects: ++ if not getattr(r, '__class__', None) is nclass: ++ continue + r.__class__ = ga ++ else: ++ for r in gc.get_referrers(nclass): ++ if getattr(r, '__class__', None) is nclass: ++ r.__class__ = ga + if doLog: + log.msg('') + log.msg(' (fixing %s): ' % str(module.__name__)) + + +_______________________________________________ +Twisted-commits mailing list +Twisted-commits@twistedmatrix.com +http://twistedmatrix.com/cgi-bin/mailman/listinfo/twisted-commits diff --git a/mail/src/leap/mail/imap/tests/__init__.py b/mail/src/leap/mail/imap/tests/__init__.py index 9a4c663..315d649 100644 --- a/mail/src/leap/mail/imap/tests/__init__.py +++ b/mail/src/leap/mail/imap/tests/__init__.py @@ -48,18 +48,19 @@ class BaseSoledadIMAPTest(BaseLeapTest): document_factory=LeapDocument) self._db2 = u1db.open(self.db2_file, create=True, document_factory=LeapDocument) + # initialize soledad by hand so we can control keys self._soledad = Soledad(self.email, gnupg_home=self.gnupg_home, - initialize=False, + bootstrap=False, prefix=self.tempdir) self._soledad._init_dirs() self._soledad._gpg = GPGWrapper(gnupghome=self.gnupg_home) - self._soledad._gpg.import_keys(PUBLIC_KEY) - self._soledad._gpg.import_keys(PRIVATE_KEY) - self._soledad._load_openpgp_keypair() - if not self._soledad._has_secret(): - self._soledad._gen_secret() - self._soledad._load_secret() + + if not self._soledad._has_privkey(): + self._soledad._set_privkey(PRIVATE_KEY) + if not self._soledad._has_symkey(): + self._soledad._gen_symkey() + self._soledad._load_symkey() self._soledad._init_db() def tearDown(self): diff --git a/mail/src/leap/mail/imap/tests/test_imap.py b/mail/src/leap/mail/imap/tests/test_imap.py index 7bfa1d7..6b6c24e 100644 --- a/mail/src/leap/mail/imap/tests/test_imap.py +++ b/mail/src/leap/mail/imap/tests/test_imap.py @@ -1,37 +1,54 @@ -#-*- encoding: utf-8 -*- +# -*- coding: utf-8 -*- +# test_imap.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. """ -leap/email/imap/tests/test_imap.py ----------------------------------- Test case for leap.email.imap.server +TestCases taken from twisted tests and modified to make them work +against SoledadBackedAccount. @authors: Kali Kaneko, <kali@leap.se> +XXX add authors from the original twisted tests. + @license: GPLv3, see included LICENSE file -@copyright: © 2013 Kali Kaneko, see COPYLEFT file """ +# XXX review license of the original tests!!! try: from cStringIO import StringIO except ImportError: from StringIO import StringIO -import codecs -import locale +#import codecs +#import locale import os import types import tempfile import shutil -from zope.interface import implements +#from zope.interface import implements -from twisted.mail.imap4 import MessageSet +#from twisted.mail.imap4 import MessageSet from twisted.mail import imap4 from twisted.protocols import loopback from twisted.internet import defer -from twisted.internet import error -from twisted.internet import reactor -from twisted.internet import interfaces -from twisted.internet.task import Clock +#from twisted.internet import error +#from twisted.internet import reactor +#from twisted.internet import interfaces +#from twisted.internet.task import Clock from twisted.trial import unittest from twisted.python import util, log from twisted.python import failure @@ -42,19 +59,20 @@ import twisted.cred.checkers import twisted.cred.credentials import twisted.cred.portal -from twisted.test.proto_helpers import StringTransport, StringTransportWithDisconnection +#from twisted.test.proto_helpers import StringTransport, StringTransportWithDisconnection -import u1db +#import u1db from leap.common.testing.basetest import BaseLeapTest from leap.mail.imap.server import SoledadMailbox -from leap.mail.tests.imap import PUBLIC_KEY -from leap.mail.tests.imap import PRIVATE_KEY +from leap.mail.imap.server import SoledadBackedAccount +from leap.mail.imap.server import MessageCollection +#from leap.mail.imap.tests import PUBLIC_KEY +#from leap.mail.imap.tests import PRIVATE_KEY from leap.soledad import Soledad -from leap.soledad.util import GPGWrapper -from leap.soledad.backends.leap_backend import LeapDocument +from leap.soledad import SoledadCrypto def strip(f): @@ -74,57 +92,61 @@ def sortNest(l): def initialize_soledad(email, gnupg_home, tempdir): """ - initializes soledad by hand + Initializes soledad by hand + + @param email: ID for the user + @param gnupg_home: path to home used by gnupg + @param tempdir: path to temporal dir + @rtype: Soledad instance """ - _soledad = Soledad(email, gnupg_home=gnupg_home, - initialize=False, - prefix=tempdir) + + uuid = "foobar-uuid" + passphrase = "verysecretpassphrase" + secret_path = os.path.join(tempdir, "secret.gpg") + local_db_path = os.path.join(tempdir, "soledad.u1db") + server_url = "http://provider" + cert_file = "" + + _soledad = Soledad( + uuid, # user's uuid, obtained through signal events + passphrase, # how to get this? + secret_path, # how to get this? + local_db_path, # how to get this? + server_url, # can be None for now + cert_file, + bootstrap=False) _soledad._init_dirs() - _soledad._gpg = GPGWrapper(gnupghome=gnupg_home) - _soledad._gpg.import_keys(PUBLIC_KEY) - _soledad._gpg.import_keys(PRIVATE_KEY) - _soledad._load_openpgp_keypair() - if not _soledad._has_secret(): - _soledad._gen_secret() - _soledad._load_secret() + _soledad._crypto = SoledadCrypto(_soledad) + _soledad._shared_db = None + _soledad._init_keys() _soledad._init_db() + return _soledad ########################################## -# account, simpleserver +# Simple LEAP IMAP4 Server for testing ########################################## +class SimpleLEAPServer(imap4.IMAP4Server): + """ + A Simple IMAP4 Server with mailboxes backed by Soledad. -class SoledadBackedAccount(imap4.MemoryAccount): - #mailboxFactory = SimpleMailbox - mailboxFactory = SoledadMailbox - soledadInstance = None - - # XXX should reimplement IAccount -> SoledadAccount - # and receive the soledad instance on the constructor. - # SoledadMailbox should allow to filter by mailbox name - # _soledad db should include mailbox field - # and a document with "INDEX" info (mailboxes / subscriptions) - - def _emptyMailbox(self, name, id): - return self.mailboxFactory(self.soledadInstance) - - def select(self, name, rw=1): - # XXX rethink this. - # Need to be classmethods... - mbox = imap4.MemoryAccount.select(self, name) - if mbox is not None: - mbox.rw = rw - return mbox + This should be pretty close to the real LeapIMAP4Server that we + will be instantiating as a service, minus the authentication bits. + """ + def __init__(self, *args, **kw): + soledad = kw.pop('soledad', None) -class SimpleLEAPServer(imap4.IMAP4Server): - def __init__(self, *args, **kw): imap4.IMAP4Server.__init__(self, *args, **kw) realm = TestRealm() - realm.theAccount = SoledadBackedAccount('testuser') - # XXX soledadInstance here? + + # XXX Why I AM PASSING THE ACCOUNT TO + # REALM? I AM NOT USING THAT NOW, AM I??? + realm.theAccount = SoledadBackedAccount( + 'testuser', + soledad=soledad) portal = cred.portal.Portal(realm) c = cred.checkers.InMemoryUsernamePasswordDatabaseDontUse() @@ -150,17 +172,25 @@ class SimpleLEAPServer(imap4.IMAP4Server): class TestRealm: + """ + A minimal auth realm for testing purposes only + """ theAccount = None def requestAvatar(self, avatarId, mind, *interfaces): return imap4.IAccount, self.theAccount, lambda: None -###################### -# Test LEAP Server -###################### + +###################################### +# Simple IMAP4 Client for testing +###################################### class SimpleClient(imap4.IMAP4Client): + """ + A Simple IMAP4 Client to test our + Soledad-LEAPServer + """ def __init__(self, deferred, contextFactory=None): imap4.IMAP4Client.__init__(self, contextFactory) @@ -184,12 +214,28 @@ class SimpleClient(imap4.IMAP4Client): class IMAP4HelperMixin(BaseLeapTest): + """ + MixIn containing several utilities to be shared across + different TestCases + """ serverCTX = None clientCTX = None @classmethod def setUpClass(cls): + """ + TestCase initialization setup. + Sets up a new environment. + Initializes a SINGLE Soledad Instance that will be shared + by all tests in this base class. + This breaks orthogonality, avoiding us to use trial, so we should + move away from this test design. But it's a quick way to get + started without knowing / mocking the soledad api. + + We do also some duplication with BaseLeapTest cause trial and nose + seem not to deal well with deriving classmethods. + """ cls.old_path = os.environ['PATH'] cls.old_home = os.environ['HOME'] cls.tempdir = tempfile.mkdtemp(prefix="leap_tests-") @@ -217,10 +263,20 @@ class IMAP4HelperMixin(BaseLeapTest): cls.gnupg_home, cls.tempdir) - cls.sm = SoledadMailbox(soledad=cls._soledad) + # now we're passing the mailbox name, so we + # should get this into a partial or something. + #cls.sm = SoledadMailbox("mailbox", soledad=cls._soledad) + # XXX REFACTOR --- self.server (in setUp) is initializing + # a SoledadBackedAccount @classmethod def tearDownClass(cls): + """ + TestCase teardown method. + + Restores the old path and home environment variables. + Removes the temporal dir created for tests. + """ #cls._db1.close() #cls._db2.close() cls._soledad.close() @@ -232,33 +288,79 @@ class IMAP4HelperMixin(BaseLeapTest): shutil.rmtree(cls.tempdir) def setUp(self): + """ + Setup method for each test. + + Initializes and run a LEAP IMAP4 Server, + but passing the same Soledad instance (it's costly to initialize), + so we have to be sure to restore state across tests. + """ d = defer.Deferred() - self.server = SimpleLEAPServer(contextFactory=self.serverCTX) + self.server = SimpleLEAPServer( + contextFactory=self.serverCTX, + # XXX do we really need this?? + soledad=self._soledad) + self.client = SimpleClient(d, contextFactory=self.clientCTX) self.connected = d - theAccount = SoledadBackedAccount('testuser') - theAccount.soledadInstance = self._soledad + # XXX REVIEW-ME. + # We're adding theAccount here to server + # but it was also passed to initialization + # as it was passed to realm. + # I THINK we ONLY need to do it at one place now. - # XXX used for something??? - #theAccount.mboxType = SoledadMailbox + theAccount = SoledadBackedAccount( + 'testuser', + soledad=self._soledad) SimpleLEAPServer.theAccount = theAccount + # in case we get something from previous tests... + for mb in self.server.theAccount.mailboxes: + self.server.theAccount.delete(mb) + def tearDown(self): + """ + tearDown method called after each test. + + Deletes all documents in the Index, and deletes + instances of server and client. + """ self.delete_all_docs() + acct = self.server.theAccount + for mb in acct.mailboxes: + acct.delete(mb) + + # FIXME add again + #for subs in acct.subscriptions: + #acct.unsubscribe(subs) + del self.server del self.client del self.connected def populateMessages(self): - self._soledad.messages.add_msg(subject="test1") - self._soledad.messages.add_msg(subject="test2") - self._soledad.messages.add_msg(subject="test3") + """ + Populates soledad instance with several simple messages + """ + # XXX we should encapsulate this thru SoledadBackedAccount + # instead. + + # XXX we also should put this in a mailbox! + + self._soledad.messages.add_msg('', subject="test1") + self._soledad.messages.add_msg('', subject="test2") + self._soledad.messages.add_msg('', subject="test3") # XXX should change Flags too - self._soledad.messages.add_msg(subject="test4") + self._soledad.messages.add_msg('', subject="test4") def delete_all_docs(self): - self.server.theAccount.messages.deleteAllDocs() + """ + Deletes all the docs in the testing instance of the + SoledadBackedAccount. + """ + self.server.theAccount.deleteAllMessages( + iknowhatiamdoing=True) def _cbStopClient(self, ignore): self.client.transport.loseConnection() @@ -272,206 +374,83 @@ class IMAP4HelperMixin(BaseLeapTest): return loopback.loopbackAsync(self.server, self.client) -class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): - - def testCapability(self): - caps = {} - - def getCaps(): - def gotCaps(c): - caps.update(c) - self.server.transport.loseConnection() - return self.client.getCapabilities().addCallback(gotCaps) - d1 = self.connected.addCallback( - strip(getCaps)).addErrback(self._ebGeneral) - d = defer.gatherResults([self.loopback(), d1]) - expected = {'IMAP4rev1': None, 'NAMESPACE': None, 'IDLE': None} - - return d.addCallback(lambda _: self.assertEqual(expected, caps)) - - def testCapabilityWithAuth(self): - caps = {} - self.server.challengers[ - 'CRAM-MD5'] = cred.credentials.CramMD5Credentials - - def getCaps(): - def gotCaps(c): - caps.update(c) - self.server.transport.loseConnection() - return self.client.getCapabilities().addCallback(gotCaps) - d1 = self.connected.addCallback( - strip(getCaps)).addErrback(self._ebGeneral) - d = defer.gatherResults([self.loopback(), d1]) - - expCap = {'IMAP4rev1': None, 'NAMESPACE': None, - 'IDLE': None, 'AUTH': ['CRAM-MD5']} - - return d.addCallback(lambda _: self.assertEqual(expCap, caps)) - - def testLogout(self): - self.loggedOut = 0 - - def logout(): - def setLoggedOut(): - self.loggedOut = 1 - self.client.logout().addCallback(strip(setLoggedOut)) - self.connected.addCallback(strip(logout)).addErrback(self._ebGeneral) - d = self.loopback() - return d.addCallback(lambda _: self.assertEqual(self.loggedOut, 1)) - - def testNoop(self): - self.responses = None - - def noop(): - def setResponses(responses): - self.responses = responses - self.server.transport.loseConnection() - self.client.noop().addCallback(setResponses) - self.connected.addCallback(strip(noop)).addErrback(self._ebGeneral) - d = self.loopback() - return d.addCallback(lambda _: self.assertEqual(self.responses, [])) - - def testLogin(self): - def login(): - d = self.client.login('testuser', 'password-test') - d.addCallback(self._cbStopClient) - d1 = self.connected.addCallback(strip(login)).addErrback(self._ebGeneral) - d = defer.gatherResults([d1, self.loopback()]) - return d.addCallback(self._cbTestLogin) - - def _cbTestLogin(self, ignored): - self.assertEqual(self.server.account, SimpleLEAPServer.theAccount) - self.assertEqual(self.server.state, 'auth') - - def testFailedLogin(self): - def login(): - d = self.client.login('testuser', 'wrong-password') - d.addBoth(self._cbStopClient) - - d1 = self.connected.addCallback(strip(login)).addErrback(self._ebGeneral) - d2 = self.loopback() - d = defer.gatherResults([d1, d2]) - return d.addCallback(self._cbTestFailedLogin) - - def _cbTestFailedLogin(self, ignored): - self.assertEqual(self.server.account, None) - self.assertEqual(self.server.state, 'unauth') - - - def testLoginRequiringQuoting(self): - self.server._username = '{test}user' - self.server._password = '{test}password' - - def login(): - d = self.client.login('{test}user', '{test}password') - d.addBoth(self._cbStopClient) - - d1 = self.connected.addCallback(strip(login)).addErrback(self._ebGeneral) - d = defer.gatherResults([self.loopback(), d1]) - return d.addCallback(self._cbTestLoginRequiringQuoting) - - def _cbTestLoginRequiringQuoting(self, ignored): - self.assertEqual(self.server.account, SimpleLEAPServer.theAccount) - self.assertEqual(self.server.state, 'auth') - - - def testNamespace(self): - self.namespaceArgs = None - def login(): - return self.client.login('testuser', 'password-test') - def namespace(): - def gotNamespace(args): - self.namespaceArgs = args - self._cbStopClient(None) - return self.client.namespace().addCallback(gotNamespace) - - d1 = self.connected.addCallback(strip(login)) - d1.addCallback(strip(namespace)) - d1.addErrback(self._ebGeneral) - d2 = self.loopback() - d = defer.gatherResults([d1, d2]) - d.addCallback(lambda _: self.assertEqual(self.namespaceArgs, - [[['', '/']], [], []])) - return d - - def testSelect(self): - SimpleLEAPServer.theAccount.addMailbox('test-mailbox') - self.selectedArgs = None - - def login(): - return self.client.login('testuser', 'password-test') - - def select(): - def selected(args): - self.selectedArgs = args - self._cbStopClient(None) - d = self.client.select('test-mailbox') - d.addCallback(selected) - return d +# +# TestCases +# - d1 = self.connected.addCallback(strip(login)) - d1.addCallback(strip(select)) - d1.addErrback(self._ebGeneral) - d2 = self.loopback() - return defer.gatherResults([d1, d2]).addCallback(self._cbTestSelect) - - def _cbTestSelect(self, ignored): - mbox = SimpleLEAPServer.theAccount.mailboxes['TEST-MAILBOX'] - self.assertEqual(self.server.mbox, mbox) - self.assertEqual(self.selectedArgs, { - 'EXISTS': 9, 'RECENT': 3, 'UIDVALIDITY': 42, - 'FLAGS': ('\\Seen', '\\Answered', '\\Flagged', - '\\Deleted', '\\Draft', '\\Recent', 'List'), - 'READ-WRITE': 1 - }) - - def test_examine(self): +class MessageCollectionTestCase(IMAP4HelperMixin, unittest.TestCase): + """ + Tests for the MessageCollection class + """ + def setUp(self): """ - L{IMAP4Client.examine} issues an I{EXAMINE} command to the server and - returns a L{Deferred} which fires with a C{dict} with as many of the - following keys as the server includes in its response: C{'FLAGS'}, - C{'EXISTS'}, C{'RECENT'}, C{'UNSEEN'}, C{'READ-WRITE'}, C{'READ-ONLY'}, - C{'UIDVALIDITY'}, and C{'PERMANENTFLAGS'}. - - Unfortunately the server doesn't generate all of these so it's hard to - test the client's handling of them here. See - L{IMAP4ClientExamineTests} below. - - See U{RFC 3501<http://www.faqs.org/rfcs/rfc3501.html>}, section 6.3.2, - for details. + setUp method for each test + We override mixin method since we are only testing + MessageCollection interface in this particular TestCase """ - SimpleLEAPServer.theAccount.addMailbox('test-mailbox') - self.examinedArgs = None - - def login(): - return self.client.login('testuser', 'password-test') + self.messages = MessageCollection("testmbox", self._soledad._db) - def examine(): - def examined(args): - self.examinedArgs = args - self._cbStopClient(None) - d = self.client.examine('test-mailbox') - d.addCallback(examined) - return d + def tearDown(self): + """ + tearDown method for each test + Delete the message collection + """ + del self.messages - d1 = self.connected.addCallback(strip(login)) - d1.addCallback(strip(examine)) - d1.addErrback(self._ebGeneral) - d2 = self.loopback() - d = defer.gatherResults([d1, d2]) - return d.addCallback(self._cbTestExamine) + def testEmptyMessage(self): + """ + Test empty message and collection + """ + em = self.messages.get_empty_msg() + self.assertEqual(em, + {"subject": "", "seen": False, + "flags": [], "mailbox": "inbox", + "mbox-uid": 1, + "raw": ""}) + self.assertEqual(self.messages.count(), 0) + + def testFilterByMailbox(self): + """ + Test that queries filter by selected mailbox + """ + mc = self.messages + mc.add_msg('', subject="test1") + mc.add_msg('', subject="test2") + mc.add_msg('', subject="test3") + self.assertEqual(self.messages.count(), 3) + + newmsg = mc.get_empty_msg() + newmsg['mailbox'] = "mailbox/foo" + newmsg['subject'] = "test another mailbox" + mc.db.create_doc(newmsg) + self.assertEqual(mc.count(), 3) + self.assertEqual(len(mc.db.get_from_index(mc.MAILBOX_INDEX, "*")), + 4) + + +class LeapIMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): + """ + Tests for the generic behavior of the LeapIMAP4Server + which, right now, it's just implemented in this test file as + SimpleLEAPServer. We will move the implementation, together with + authentication bits, to leap.mail.imap.server so it can be instantiated + from the tac file. + + Right now this TestCase tries to mimmick as close as possible the + organization from the twisted.mail.imap tests so we can achieve + a complete implementation. The order in which they appear reflect + the intended order of implementation. + """ - def _cbTestExamine(self, ignored): - mbox = SimpleLEAPServer.theAccount.mailboxes['TEST-MAILBOX'] - self.assertEqual(self.server.mbox, mbox) - self.assertEqual(self.examinedArgs, { - 'EXISTS': 9, 'RECENT': 3, 'UIDVALIDITY': 42, - 'FLAGS': ('\\Seen', '\\Answered', '\\Flagged', - '\\Deleted', '\\Draft', '\\Recent', 'List'), - 'READ-WRITE': False}) + # + # mailboxes operations + # def testCreate(self): - succeed = ('testbox', 'test/box', 'test/', 'test/box/box', 'INBOX') + """ + Test whether we can create mailboxes + """ + succeed = ('testbox', 'test/box', 'test/', 'test/box/box', 'FOOBOX') fail = ('testbox', 'test/box') def cb(): @@ -498,13 +477,17 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): def _cbTestCreate(self, ignored, succeed, fail): self.assertEqual(self.result, [1] * len(succeed) + [0] * len(fail)) - mbox = SimpleLEAPServer.theAccount.mailboxes.keys() - answers = ['inbox', 'testbox', 'test/box', 'test', 'test/box/box'] + + mbox = SimpleLEAPServer.theAccount.mailboxes + answers = ['foobox', 'testbox', 'test/box', 'test', 'test/box/box'] mbox.sort() answers.sort() self.assertEqual(mbox, [a.upper() for a in answers]) def testDelete(self): + """ + Test whether we can delete mailboxes + """ SimpleLEAPServer.theAccount.addMailbox('delete/me') def login(): @@ -518,11 +501,16 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): d1.addCallbacks(self._cbStopClient, self._ebGeneral) d2 = self.loopback() d = defer.gatherResults([d1, d2]) - d.addCallback(lambda _: - self.assertEqual(SimpleLEAPServer.theAccount.mailboxes.keys(), [])) + d.addCallback( + lambda _: self.assertEqual( + SimpleLEAPServer.theAccount.mailboxes, [])) return d def testIllegalInboxDelete(self): + """ + Test what happens if we try to delete the user Inbox. + We expect that operation to fail. + """ self.stashed = None def login(): @@ -545,12 +533,16 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): return d def testNonExistentDelete(self): - + """ + Test what happens if we try to delete a non-existent mailbox. + We expect an error raised stating 'No such inbox' + """ def login(): return self.client.login('testuser', 'password-test') def delete(): return self.client.delete('delete/me') + self.failure = failure def deleteFailed(failure): self.failure = failure @@ -562,13 +554,17 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): d2 = self.loopback() d = defer.gatherResults([d1, d2]) d.addCallback(lambda _: self.assertEqual(str(self.failure.value), - 'No such mailbox')) + 'No such mailbox')) return d def testIllegalDelete(self): - m = SoledadMailbox() - m.flags = (r'\Noselect',) - SimpleLEAPServer.theAccount.addMailbox('delete', m) + """ + Try deleting a mailbox with sub-folders, and \NoSelect flag set. + An exception is expected + """ + SimpleLEAPServer.theAccount.addMailbox('delete') + to_delete = SimpleLEAPServer.theAccount.getMailbox('delete') + to_delete.setFlags((r'\Noselect',)) SimpleLEAPServer.theAccount.addMailbox('delete/me') def login(): @@ -593,6 +589,9 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): return d def testRename(self): + """ + Test whether we can rename a mailbox + """ SimpleLEAPServer.theAccount.addMailbox('oldmbox') def login(): @@ -608,11 +607,15 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): d = defer.gatherResults([d1, d2]) d.addCallback(lambda _: self.assertEqual( - SimpleLEAPServer.theAccount.mailboxes.keys(), - ['NEWNAME'])) + SimpleLEAPServer.theAccount.mailboxes, + ['NEWNAME'])) return d def testIllegalInboxRename(self): + """ + Try to rename inbox. We expect it to fail. Then it would be not + an inbox anymore, would it? + """ self.stashed = None def login(): @@ -632,10 +635,13 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): d = defer.gatherResults([d1, d2]) d.addCallback(lambda _: self.failUnless(isinstance( - self.stashed, failure.Failure))) + self.stashed, failure.Failure))) return d def testHierarchicalRename(self): + """ + Try to rename hierarchical mailboxes + """ SimpleLEAPServer.theAccount.create('oldmbox/m1') SimpleLEAPServer.theAccount.create('oldmbox/m2') @@ -653,13 +659,15 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): return d.addCallback(self._cbTestHierarchicalRename) def _cbTestHierarchicalRename(self, ignored): - mboxes = SimpleLEAPServer.theAccount.mailboxes.keys() + mboxes = SimpleLEAPServer.theAccount.mailboxes expected = ['newname', 'newname/m1', 'newname/m2'] mboxes.sort() self.assertEqual(mboxes, [s.upper() for s in expected]) def testSubscribe(self): - + """ + Test whether we can mark a mailbox as subscribed to + """ def login(): return self.client.login('testuser', 'password-test') @@ -672,14 +680,21 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): d2 = self.loopback() d = defer.gatherResults([d1, d2]) d.addCallback(lambda _: - self.assertEqual(SimpleLEAPServer.theAccount.subscriptions, - ['THIS/MBOX'])) + self.assertEqual( + SimpleLEAPServer.theAccount.subscriptions, + ['THIS/MBOX'])) return d def testUnsubscribe(self): - SimpleLEAPServer.theAccount.subscriptions = ['THIS/MBOX', 'THAT/MBOX'] + """ + Test whether we can unsubscribe from a set of mailboxes + """ + SimpleLEAPServer.theAccount.subscribe('THIS/MBOX') + SimpleLEAPServer.theAccount.subscribe('THAT/MBOX') + def login(): return self.client.login('testuser', 'password-test') + def unsubscribe(): return self.client.unsubscribe('this/mbox') @@ -689,14 +704,255 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): d2 = self.loopback() d = defer.gatherResults([d1, d2]) d.addCallback(lambda _: - self.assertEqual(SimpleLEAPServer.theAccount.subscriptions, - ['THAT/MBOX'])) + self.assertEqual( + SimpleLEAPServer.theAccount.subscriptions, + ['THAT/MBOX'])) + return d + + def testSelect(self): + """ + Try to select a mailbox + """ + self.server.theAccount.addMailbox('TESTMAILBOX-SELECT', creation_ts=42) + self.selectedArgs = None + + def login(): + return self.client.login('testuser', 'password-test') + + def select(): + def selected(args): + self.selectedArgs = args + self._cbStopClient(None) + d = self.client.select('TESTMAILBOX-SELECT') + d.addCallback(selected) + return d + + d1 = self.connected.addCallback(strip(login)) + d1.addCallback(strip(select)) + d1.addErrback(self._ebGeneral) + + d2 = self.loopback() + return defer.gatherResults([d1, d2]).addCallback(self._cbTestSelect) + + def _cbTestSelect(self, ignored): + mbox = SimpleLEAPServer.theAccount.getMailbox('TESTMAILBOX-SELECT') + self.assertEqual(self.server.mbox.messages.mbox, mbox.messages.mbox) + self.assertEqual(self.selectedArgs, { + 'EXISTS': 0, 'RECENT': 0, 'UIDVALIDITY': 42, + 'FLAGS': ('\\Seen', '\\Answered', '\\Flagged', + '\\Deleted', '\\Draft', '\\Recent', 'List'), + 'READ-WRITE': True + }) + + # + # capabilities + # + + def testCapability(self): + caps = {} + + def getCaps(): + def gotCaps(c): + caps.update(c) + self.server.transport.loseConnection() + return self.client.getCapabilities().addCallback(gotCaps) + d1 = self.connected.addCallback( + strip(getCaps)).addErrback(self._ebGeneral) + d = defer.gatherResults([self.loopback(), d1]) + expected = {'IMAP4rev1': None, 'NAMESPACE': None, 'IDLE': None} + + return d.addCallback(lambda _: self.assertEqual(expected, caps)) + + def testCapabilityWithAuth(self): + caps = {} + self.server.challengers[ + 'CRAM-MD5'] = cred.credentials.CramMD5Credentials + + def getCaps(): + def gotCaps(c): + caps.update(c) + self.server.transport.loseConnection() + return self.client.getCapabilities().addCallback(gotCaps) + d1 = self.connected.addCallback( + strip(getCaps)).addErrback(self._ebGeneral) + + d = defer.gatherResults([self.loopback(), d1]) + + expCap = {'IMAP4rev1': None, 'NAMESPACE': None, + 'IDLE': None, 'AUTH': ['CRAM-MD5']} + + return d.addCallback(lambda _: self.assertEqual(expCap, caps)) + + # + # authentication + # + + def testLogout(self): + """ + Test log out + """ + self.loggedOut = 0 + + def logout(): + def setLoggedOut(): + self.loggedOut = 1 + self.client.logout().addCallback(strip(setLoggedOut)) + self.connected.addCallback(strip(logout)).addErrback(self._ebGeneral) + d = self.loopback() + return d.addCallback(lambda _: self.assertEqual(self.loggedOut, 1)) + + def testNoop(self): + """ + Test noop command + """ + self.responses = None + + def noop(): + def setResponses(responses): + self.responses = responses + self.server.transport.loseConnection() + self.client.noop().addCallback(setResponses) + self.connected.addCallback(strip(noop)).addErrback(self._ebGeneral) + d = self.loopback() + return d.addCallback(lambda _: self.assertEqual(self.responses, [])) + + def testLogin(self): + """ + Test login + """ + def login(): + d = self.client.login('testuser', 'password-test') + d.addCallback(self._cbStopClient) + d1 = self.connected.addCallback( + strip(login)).addErrback(self._ebGeneral) + d = defer.gatherResults([d1, self.loopback()]) + return d.addCallback(self._cbTestLogin) + + def _cbTestLogin(self, ignored): + self.assertEqual(self.server.account, SimpleLEAPServer.theAccount) + self.assertEqual(self.server.state, 'auth') + + def testFailedLogin(self): + """ + Test bad login + """ + def login(): + d = self.client.login('testuser', 'wrong-password') + d.addBoth(self._cbStopClient) + + d1 = self.connected.addCallback( + strip(login)).addErrback(self._ebGeneral) + d2 = self.loopback() + d = defer.gatherResults([d1, d2]) + return d.addCallback(self._cbTestFailedLogin) + + def _cbTestFailedLogin(self, ignored): + self.assertEqual(self.server.account, None) + self.assertEqual(self.server.state, 'unauth') + + def testLoginRequiringQuoting(self): + """ + Test login requiring quoting + """ + self.server._username = '{test}user' + self.server._password = '{test}password' + + def login(): + d = self.client.login('{test}user', '{test}password') + d.addBoth(self._cbStopClient) + + d1 = self.connected.addCallback( + strip(login)).addErrback(self._ebGeneral) + d = defer.gatherResults([self.loopback(), d1]) + return d.addCallback(self._cbTestLoginRequiringQuoting) + + def _cbTestLoginRequiringQuoting(self, ignored): + self.assertEqual(self.server.account, SimpleLEAPServer.theAccount) + self.assertEqual(self.server.state, 'auth') + + # + # Inspection + # + + def testNamespace(self): + """ + Test retrieving namespace + """ + self.namespaceArgs = None + + def login(): + return self.client.login('testuser', 'password-test') + + def namespace(): + def gotNamespace(args): + self.namespaceArgs = args + self._cbStopClient(None) + return self.client.namespace().addCallback(gotNamespace) + + d1 = self.connected.addCallback(strip(login)) + d1.addCallback(strip(namespace)) + d1.addErrback(self._ebGeneral) + d2 = self.loopback() + d = defer.gatherResults([d1, d2]) + d.addCallback(lambda _: self.assertEqual(self.namespaceArgs, + [[['', '/']], [], []])) return d + def testExamine(self): + """ + L{IMAP4Client.examine} issues an I{EXAMINE} command to the server and + returns a L{Deferred} which fires with a C{dict} with as many of the + following keys as the server includes in its response: C{'FLAGS'}, + C{'EXISTS'}, C{'RECENT'}, C{'UNSEEN'}, C{'READ-WRITE'}, C{'READ-ONLY'}, + C{'UIDVALIDITY'}, and C{'PERMANENTFLAGS'}. + + Unfortunately the server doesn't generate all of these so it's hard to + test the client's handling of them here. See + L{IMAP4ClientExamineTests} below. + + See U{RFC 3501<http://www.faqs.org/rfcs/rfc3501.html>}, section 6.3.2, + for details. + """ + self.server.theAccount.addMailbox('test-mailbox-e', + creation_ts=42) + #import ipdb; ipdb.set_trace() + + self.examinedArgs = None + + def login(): + return self.client.login('testuser', 'password-test') + + def examine(): + def examined(args): + self.examinedArgs = args + self._cbStopClient(None) + d = self.client.examine('test-mailbox-e') + d.addCallback(examined) + return d + + d1 = self.connected.addCallback(strip(login)) + d1.addCallback(strip(examine)) + d1.addErrback(self._ebGeneral) + d2 = self.loopback() + d = defer.gatherResults([d1, d2]) + return d.addCallback(self._cbTestExamine) + + def _cbTestExamine(self, ignored): + mbox = self.server.theAccount.getMailbox('TEST-MAILBOX-E') + self.assertEqual(self.server.mbox.messages.mbox, mbox.messages.mbox) + self.assertEqual(self.examinedArgs, { + 'EXISTS': 0, 'RECENT': 0, 'UIDVALIDITY': 42, + 'FLAGS': ('\\Seen', '\\Answered', '\\Flagged', + '\\Deleted', '\\Draft', '\\Recent', 'List'), + 'READ-WRITE': False}) + def _listSetup(self, f): - SimpleLEAPServer.theAccount.addMailbox('root/subthing') - SimpleLEAPServer.theAccount.addMailbox('root/another-thing') - SimpleLEAPServer.theAccount.addMailbox('non-root/subthing') + SimpleLEAPServer.theAccount.addMailbox('root/subthingl', + creation_ts=42) + SimpleLEAPServer.theAccount.addMailbox('root/another-thing', + creation_ts=42) + SimpleLEAPServer.theAccount.addMailbox('non-root/subthing', + creation_ts=42) def login(): return self.client.login('testuser', 'password-test') @@ -713,37 +969,51 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): return defer.gatherResults([d1, d2]).addCallback(lambda _: self.listed) def testList(self): + """ + Test List command + """ def list(): return self.client.list('root', '%') d = self._listSetup(list) d.addCallback(lambda listed: self.assertEqual( sortNest(listed), sortNest([ - (SoledadMailbox.flags, "/", "ROOT/SUBTHING"), - (SoledadMailbox.flags, "/", "ROOT/ANOTHER-THING") + (SoledadMailbox.INIT_FLAGS, "/", "ROOT/SUBTHINGL"), + (SoledadMailbox.INIT_FLAGS, "/", "ROOT/ANOTHER-THING") ]) )) return d + # XXX implement subscriptions + ''' def testLSub(self): - SimpleLEAPServer.theAccount.subscribe('ROOT/SUBTHING') + """ + Test LSub command + """ + SimpleLEAPServer.theAccount.subscribe('ROOT/SUBTHINGL') def lsub(): return self.client.lsub('root', '%') d = self._listSetup(lsub) d.addCallback(self.assertEqual, - [(SoledadMailbox.flags, "/", "ROOT/SUBTHING")]) + [(SoledadMailbox.INIT_FLAGS, "/", "ROOT/SUBTHINGL")]) return d + ''' def testStatus(self): - SimpleLEAPServer.theAccount.addMailbox('root/subthing') + """ + Test Status command + """ + SimpleLEAPServer.theAccount.addMailbox('root/subthings') + # XXX FIXME ---- should populate this a little bit, + # with unseen etc... def login(): return self.client.login('testuser', 'password-test') def status(): return self.client.status( - 'root/subthing', 'MESSAGES', 'UIDNEXT', 'UNSEEN') + 'root/subthings', 'MESSAGES', 'UIDNEXT', 'UNSEEN') def statused(result): self.statused = result @@ -757,11 +1027,14 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): d = defer.gatherResults([d1, d2]) d.addCallback(lambda _: self.assertEqual( self.statused, - {'MESSAGES': 9, 'UIDNEXT': '10', 'UNSEEN': 4} + {'MESSAGES': 0, 'UIDNEXT': '1', 'UNSEEN': 0} )) return d def testFailedStatus(self): + """ + Test failed status command with a non-existent mailbox + """ def login(): return self.client.login('testuser', 'password-test') @@ -793,7 +1066,14 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): ('Could not open mailbox',) ) + # + # messages + # + def testFullAppend(self): + """ + Test appending a full message to the mailbox + """ infile = util.sibpath(__file__, 'rfc822.message') message = open(infile) SimpleLEAPServer.theAccount.addMailbox('root/subthing') @@ -805,7 +1085,7 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): return self.client.append( 'root/subthing', message, - ('\\SEEN', '\\DELETED'), + ['\\SEEN', '\\DELETED'], 'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)', ) @@ -817,15 +1097,24 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): return d.addCallback(self._cbTestFullAppend, infile) def _cbTestFullAppend(self, ignored, infile): - mb = SimpleLEAPServer.theAccount.mailboxes['ROOT/SUBTHING'] + mb = SimpleLEAPServer.theAccount.getMailbox('ROOT/SUBTHING') self.assertEqual(1, len(mb.messages)) + + #import ipdb; ipdb.set_trace() self.assertEqual( - (['\\SEEN', '\\DELETED'], 'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)', 0), - mb.messages[0][1:] - ) - self.assertEqual(open(infile).read(), mb.messages[0][0].getvalue()) + ['\\SEEN', '\\DELETED'], + mb.messages[1]['flags']) + + self.assertEqual( + 'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)', + mb.messages[1]['date']) + + self.assertEqual(open(infile).read(), mb.messages[1]['raw']) def testPartialAppend(self): + """ + Test partially appending a message to the mailbox + """ infile = util.sibpath(__file__, 'rfc822.message') message = open(infile) SimpleLEAPServer.theAccount.addMailbox('PARTIAL/SUBTHING') @@ -838,7 +1127,8 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): return self.client.sendCommand( imap4.Command( 'APPEND', - 'PARTIAL/SUBTHING (\\SEEN) "Right now" {%d}' % os.path.getsize(infile), + 'PARTIAL/SUBTHING (\\SEEN) "Right now" ' + '{%d}' % os.path.getsize(infile), (), self.client._IMAP4Client__cbContinueAppend, message ) ) @@ -850,15 +1140,20 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): return d.addCallback(self._cbTestPartialAppend, infile) def _cbTestPartialAppend(self, ignored, infile): - mb = SimpleLEAPServer.theAccount.mailboxes['PARTIAL/SUBTHING'] + mb = SimpleLEAPServer.theAccount.getMailbox('PARTIAL/SUBTHING') self.assertEqual(1, len(mb.messages)) self.assertEqual( - (['\\SEEN'], 'Right now', 0), - mb.messages[0][1:] + ['\\SEEN',], + mb.messages[1]['flags'] ) - self.assertEqual(open(infile).read(), mb.messages[0][0].getvalue()) + self.assertEqual( + 'Right now', mb.messages[1]['date']) + self.assertEqual(open(infile).read(), mb.messages[1]['raw']) def testCheck(self): + """ + Test check command + """ SimpleLEAPServer.theAccount.addMailbox('root/subthing') def login(): @@ -879,19 +1174,25 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): # Okay, that was fun def testClose(self): - m = SoledadMailbox() - m.messages = [ - ('Message 1', ('\\Deleted', 'AnotherFlag'), None, 0), - ('Message 2', ('AnotherFlag',), None, 1), - ('Message 3', ('\\Deleted',), None, 2), - ] - SimpleLEAPServer.theAccount.addMailbox('mailbox', m) + """ + Test closing the mailbox. We expect to get deleted all messages flagged + as such. + """ + name = 'mailbox-close' + self.server.theAccount.addMailbox(name) + #import ipdb; ipdb.set_trace() + + m = SimpleLEAPServer.theAccount.getMailbox(name) + m.messages.add_msg('', subject="Message 1", + flags=('\\Deleted', 'AnotherFlag')) + m.messages.add_msg('', subject="Message 2", flags=('AnotherFlag',)) + m.messages.add_msg('', subject="Message 3", flags=('\\Deleted',)) def login(): return self.client.login('testuser', 'password-test') def select(): - return self.client.select('mailbox') + return self.client.select(name) def close(): return self.client.close() @@ -905,24 +1206,29 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): def _cbTestClose(self, ignored, m): self.assertEqual(len(m.messages), 1) - self.assertEqual(m.messages[0], - ('Message 2', ('AnotherFlag',), None, 1)) + self.assertEqual( + m.messages[1]['subject'], + 'Message 2') + self.failUnless(m.closed) def testExpunge(self): - m = SoledadMailbox() - m.messages = [ - ('Message 1', ('\\Deleted', 'AnotherFlag'), None, 0), - ('Message 2', ('AnotherFlag',), None, 1), - ('Message 3', ('\\Deleted',), None, 2), - ] - SimpleLEAPServer.theAccount.addMailbox('mailbox', m) + """ + Test expunge command + """ + name = 'mailbox-expunge' + SimpleLEAPServer.theAccount.addMailbox(name) + m = SimpleLEAPServer.theAccount.getMailbox(name) + m.messages.add_msg('', subject="Message 1", + flags=('\\Deleted', 'AnotherFlag')) + m.messages.add_msg('', subject="Message 2", flags=('AnotherFlag',)) + m.messages.add_msg('', subject="Message 3", flags=('\\Deleted',)) def login(): return self.client.login('testuser', 'password-test') def select(): - return self.client.select('mailbox') + return self.client.select('mailbox-expunge') def expunge(): return self.client.expunge() @@ -943,15 +1249,16 @@ class IMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): def _cbTestExpunge(self, ignored, m): self.assertEqual(len(m.messages), 1) - self.assertEqual(m.messages[0], - ('Message 2', ('AnotherFlag',), None, 1)) - - self.assertEqual(self.results, [0, 2]) - + self.assertEqual( + m.messages[1]['subject'], + 'Message 2') + self.assertEqual(self.results, [0, 1]) + # XXX fix this thing with the indexes... class IMAP4ServerSearchTestCase(IMAP4HelperMixin, unittest.TestCase): """ Tests for the behavior of the search_* functions in L{imap4.IMAP4Server}. """ + # XXX coming soon to your screens! pass |