summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.rst8
-rw-r--r--pkg/requirements.pip3
-rw-r--r--src/leap/mail/imap/fetch.py89
-rw-r--r--src/leap/mail/imap/server.py1605
-rw-r--r--src/leap/mail/imap/service/README.rst39
-rw-r--r--src/leap/mail/imap/service/imap-server.tac178
-rw-r--r--src/leap/mail/imap/service/notes.txt81
-rw-r--r--src/leap/mail/imap/service/rfc822.message86
-rw-r--r--src/leap/mail/imap/tests/__init__.py15
-rw-r--r--src/leap/mail/imap/tests/test_imap.py955
10 files changed, 2369 insertions, 690 deletions
diff --git a/README.rst b/README.rst
index 92a4fa6..7224cba 100644
--- a/README.rst
+++ b/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/pkg/requirements.pip b/pkg/requirements.pip
index 5f4e7ef..d8888fd 100644
--- a/pkg/requirements.pip
+++ b/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/src/leap/mail/imap/fetch.py b/src/leap/mail/imap/fetch.py
new file mode 100644
index 0000000..60ae387
--- /dev/null
+++ b/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/src/leap/mail/imap/server.py b/src/leap/mail/imap/server.py
index 4e9c22c..30938db 100644
--- a/src/leap/mail/imap/server.py
+++ b/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/src/leap/mail/imap/service/README.rst b/src/leap/mail/imap/service/README.rst
new file mode 100644
index 0000000..2cca9b3
--- /dev/null
+++ b/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/src/leap/mail/imap/service/imap-server.tac b/src/leap/mail/imap/service/imap-server.tac
new file mode 100644
index 0000000..1a4661b
--- /dev/null
+++ b/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/src/leap/mail/imap/service/notes.txt b/src/leap/mail/imap/service/notes.txt
new file mode 100644
index 0000000..623e122
--- /dev/null
+++ b/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/src/leap/mail/imap/service/rfc822.message b/src/leap/mail/imap/service/rfc822.message
new file mode 100644
index 0000000..ee97ab9
--- /dev/null
+++ b/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/src/leap/mail/imap/tests/__init__.py b/src/leap/mail/imap/tests/__init__.py
index 9a4c663..315d649 100644
--- a/src/leap/mail/imap/tests/__init__.py
+++ b/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/src/leap/mail/imap/tests/test_imap.py b/src/leap/mail/imap/tests/test_imap.py
index 7bfa1d7..6b6c24e 100644
--- a/src/leap/mail/imap/tests/test_imap.py
+++ b/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