summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKali Kaneko <kali@leap.se>2013-04-09 23:08:33 +0900
committerKali Kaneko <kali@leap.se>2013-04-09 23:09:06 +0900
commit8fb5895c46282aa913d2cf3c31f3c526174b3f3b (patch)
tree6417a78c8f74aeea1785c997c5c9d0586300b94a
Initial import
-rw-r--r--README.rst5
-rw-r--r--pkg/requirements-dev.pip14
-rw-r--r--pkg/requirements.pip3
-rw-r--r--setup.py46
-rw-r--r--src/leap/__init__.py6
-rw-r--r--src/leap/mail/__init__.py0
-rw-r--r--src/leap/mail/imap/__init__.py0
-rw-r--r--src/leap/mail/imap/server.py558
-rw-r--r--src/leap/mail/imap/tests/__init__.py232
-rwxr-xr-xsrc/leap/mail/imap/tests/imapclient.py206
-rw-r--r--src/leap/mail/imap/tests/rfc822.message86
-rw-r--r--src/leap/mail/imap/tests/test_imap.py957
-rw-r--r--src/leap/mail/smtp/README.rst43
-rw-r--r--src/leap/mail/smtp/__init__.py0
-rw-r--r--src/leap/mail/smtp/smtprelay.py246
-rw-r--r--src/leap/mail/smtp/tests/185CA770.key79
-rw-r--r--src/leap/mail/smtp/tests/185CA770.pub52
-rw-r--r--src/leap/mail/smtp/tests/__init__.py218
-rw-r--r--src/leap/mail/smtp/tests/mail.txt10
-rw-r--r--src/leap/mail/smtp/tests/test_smtprelay.py78
20 files changed, 2839 insertions, 0 deletions
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..92a4fa6
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,5 @@
+leap.mail
+=========
+Mail services for the LEAP CLient.
+
+More info: https://leap.se
diff --git a/pkg/requirements-dev.pip b/pkg/requirements-dev.pip
new file mode 100644
index 0000000..4bd76f6
--- /dev/null
+++ b/pkg/requirements-dev.pip
@@ -0,0 +1,14 @@
+# ---------------------------
+# -- external requirements --
+# -- during development --
+# ---------------------------
+#
+# For temporary work, you can point this to your developer repo.
+# consolidated changes will be pushed to pypi and then added
+# to the main requirements.pip
+#
+# NOTE: you have to run pip install -r pkg/requirements.pip for pip
+# to install it. (do it after python setup.py develop and it
+# will only install this)
+
+-e git+git://leap.se/soledad@develop#egg=leap.soledad
diff --git a/pkg/requirements.pip b/pkg/requirements.pip
new file mode 100644
index 0000000..1b5e5ef
--- /dev/null
+++ b/pkg/requirements.pip
@@ -0,0 +1,3 @@
+leap.common
+leap.soledad
+twisted
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..4de7251
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# setup.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/>.
+"""
+setup file for leap.mail
+"""
+from setuptools import setup, find_packages
+
+requirements = [
+ "leap.soledad",
+ "leap.common",
+ "twisted",
+]
+
+# XXX add classifiers, docs
+
+setup(
+ name='leap.mail',
+ version='0.2.0-dev',
+ url='https://leap.se/',
+ license='GPLv3+',
+ author='The LEAP Encryption Access Project',
+ author_email='info@leap.se',
+ description='Mail Services in the LEAP Client project.',
+ long_description=(
+ "Mail Services in the LEAP Client project."
+ ),
+ namespace_packages=["leap"],
+ package_dir={'': 'src'},
+ packages=find_packages('src'),
+ #test_suite='leap.mail.tests',
+ #install_requires=requirements,
+)
diff --git a/src/leap/__init__.py b/src/leap/__init__.py
new file mode 100644
index 0000000..f48ad10
--- /dev/null
+++ b/src/leap/__init__.py
@@ -0,0 +1,6 @@
+# See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages
+try:
+ __import__('pkg_resources').declare_namespace(__name__)
+except ImportError:
+ from pkgutil import extend_path
+ __path__ = extend_path(__path__, __name__)
diff --git a/src/leap/mail/__init__.py b/src/leap/mail/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/leap/mail/__init__.py
diff --git a/src/leap/mail/imap/__init__.py b/src/leap/mail/imap/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/leap/mail/imap/__init__.py
diff --git a/src/leap/mail/imap/server.py b/src/leap/mail/imap/server.py
new file mode 100644
index 0000000..4e9c22c
--- /dev/null
+++ b/src/leap/mail/imap/server.py
@@ -0,0 +1,558 @@
+import copy
+
+from zope.interface import implements
+
+from twisted.mail import imap4
+from twisted.internet import defer
+
+#from twisted import cred
+
+import u1db
+
+
+# TODO delete this SimpleMailbox
+class SimpleMailbox:
+ """
+ A simple Mailbox for reference
+ We don't intend to use this, only for debugging purposes
+ until we stabilize unittests with SoledadMailbox
+ """
+ implements(imap4.IMailboxInfo, imap4.IMailbox, imap4.ICloseableMailbox)
+
+ flags = ('\\Flag1', 'Flag2', '\\AnotherSysFlag', 'LastFlag')
+ messages = []
+ mUID = 0
+ rw = 1
+ closed = False
+
+ def __init__(self):
+ self.listeners = []
+ self.addListener = self.listeners.append
+ self.removeListener = self.listeners.remove
+
+ def getFlags(self):
+ return self.flags
+
+ def getUIDValidity(self):
+ return 42
+
+ def getUIDNext(self):
+ return len(self.messages) + 1
+
+ def getMessageCount(self):
+ return 9
+
+ def getRecentCount(self):
+ return 3
+
+ def getUnseenCount(self):
+ return 4
+
+ def isWriteable(self):
+ return self.rw
+
+ def destroy(self):
+ pass
+
+ def getHierarchicalDelimiter(self):
+ return '/'
+
+ def requestStatus(self, names):
+ r = {}
+ if 'MESSAGES' in names:
+ r['MESSAGES'] = self.getMessageCount()
+ if 'RECENT' in names:
+ r['RECENT'] = self.getRecentCount()
+ if 'UIDNEXT' in names:
+ r['UIDNEXT'] = self.getMessageCount() + 1
+ if 'UIDVALIDITY' in names:
+ r['UIDVALIDITY'] = self.getUID()
+ if 'UNSEEN' in names:
+ r['UNSEEN'] = self.getUnseenCount()
+ return defer.succeed(r)
+
+ def addMessage(self, message, flags, date=None):
+ self.messages.append((message, flags, date, self.mUID))
+ self.mUID += 1
+ return defer.succeed(None)
+
+ def expunge(self):
+ delete = []
+ for i in self.messages:
+ if '\\Deleted' in i[1]:
+ delete.append(i)
+ for i in delete:
+ self.messages.remove(i)
+ return [i[3] for i in delete]
+
+ def close(self):
+ self.closed = True
+
+
+###################################
+# SoledadAccount Index
+###################################
+
+class MissingIndexError(Exception):
+ """raises when tried to access a non existent index document"""
+
+
+class BadIndexError(Exception):
+ """raises when index is malformed or has the wrong cardinality"""
+
+
+EMPTY_INDEXDOC = {"is_index": True, "mailboxes": [], "subscriptions": []}
+get_empty_indexdoc = lambda: copy.deepcopy(EMPTY_INDEXDOC)
+
+
+class SoledadAccountIndex(object):
+ """
+ Index for the Soledad Account
+ keeps track of mailboxes and subscriptions
+ """
+ _index = None
+
+ def __init__(self, soledad=None):
+ self._soledad = soledad
+ self._db = soledad._db
+ self._initialize_db()
+
+ def _initialize_db(self):
+ """initialize the database"""
+ db_indexes = dict(self._soledad._db.list_indexes())
+ name, expression = "isindex", ["bool(is_index)"]
+ if name not in db_indexes:
+ self._soledad._db.create_index(name, *expression)
+ try:
+ self._index = self._get_index_doc()
+ except MissingIndexError:
+ print "no index!!! creating..."
+ self._create_index_doc()
+
+ def _create_index_doc(self):
+ """creates an empty index document"""
+ indexdoc = get_empty_indexdoc()
+ self._index = self._soledad.create_doc(
+ indexdoc)
+
+ def _get_index_doc(self):
+ """gets index document"""
+ indexdoc = self._db.get_from_index("isindex", "*")
+ if not indexdoc:
+ raise MissingIndexError
+ if len(indexdoc) > 1:
+ raise BadIndexError
+ return indexdoc[0]
+
+ def _update_index_doc(self):
+ """updates index document"""
+ self._db.put_doc(self._index)
+
+ # setters and getters for the index document
+
+ def _get_mailboxes(self):
+ """Get mailboxes associated with this account."""
+ return self._index.content.setdefault('mailboxes', [])
+
+ def _set_mailboxes(self, mailboxes):
+ """Set mailboxes associated with this account."""
+ self._index.content['mailboxes'] = list(set(mailboxes))
+ self._update_index_doc()
+
+ mailboxes = property(
+ _get_mailboxes, _set_mailboxes, doc="Account mailboxes.")
+
+ def _get_subscriptions(self):
+ """Get subscriptions associated with this account."""
+ return self._index.content.setdefault('subscriptions', [])
+
+ def _set_subscriptions(self, subscriptions):
+ """Set subscriptions associated with this account."""
+ self._index.content['subscriptions'] = list(set(subscriptions))
+ self._update_index_doc()
+
+ subscriptions = property(
+ _get_subscriptions, _set_subscriptions, doc="Account subscriptions.")
+
+ def addMailbox(self, name):
+ """add a mailbox to the mailboxes list."""
+ name = name.upper()
+ self.mailboxes.append(name)
+ self._update_index_doc()
+
+ def removeMailbox(self, name):
+ """remove a mailbox from the mailboxes list."""
+ self.mailboxes.remove(name)
+ self._update_index_doc()
+
+ def addSubscription(self, name):
+ """add a subscription to the subscriptions list."""
+ name = name.upper()
+ self.subscriptions.append(name)
+ self._update_index_doc()
+
+ def removeSubscription(self, name):
+ """remove a subscription from the subscriptions list."""
+ self.subscriptions.remove(name)
+ self._update_index_doc()
+
+
+#######################################
+# Soledad Account
+#######################################
+
+class SoledadBackedAccount(object):
+
+ implements(imap4.IAccount, imap4.INamespacePresenter)
+
+ #mailboxes = None
+ #subscriptions = None
+
+ top_id = 0 # XXX move top_id to _index
+ _soledad = None
+ _db = None
+
+ def __init__(self, name, soledad=None):
+ self.name = name
+ self._soledad = soledad
+ self._db = soledad._db
+ self._index = SoledadAccountIndex(soledad=soledad)
+
+ #self.mailboxes = {}
+ #self.subscriptions = []
+
+ def allocateID(self):
+ id = self.top_id # XXX move to index !!!
+ self.top_id += 1
+ return id
+
+ @property
+ def mailboxes(self):
+ return self._index.mailboxes
+
+ @property
+ def subscriptions(self):
+ return self._index.subscriptions
+
+ ##
+ ## IAccount
+ ##
+
+ 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
+
+ def create(self, pathspec):
+ paths = filter(None, pathspec.split('/'))
+ for accum in range(1, len(paths)):
+ try:
+ self.addMailbox('/'.join(paths[:accum]))
+ except imap4.MailboxCollision:
+ pass
+ try:
+ self.addMailbox('/'.join(paths))
+ except imap4.MailboxCollision:
+ if not pathspec.endswith('/'):
+ 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())
+
+ def delete(self, name):
+ name = name.upper()
+ # See if this mailbox exists at all
+ mbox = self.mailboxes.get(name)
+ if not mbox:
+ 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.destroy()
+
+ # iff there are no hierarchically inferior names, we will
+ # delete it from our ken.
+ if self._inferiorNames(name) > 1:
+ del self.mailboxes[name]
+
+ def rename(self, oldname, newname):
+ oldname = oldname.upper()
+ newname = newname.upper()
+ if oldname not in self.mailboxes:
+ raise imap4.NoSuchMailbox, oldname
+
+ inferiors = self._inferiorNames(oldname)
+ inferiors = [(o, o.replace(oldname, newname, 1)) for o in inferiors]
+
+ for (old, new) in inferiors:
+ if new in self.mailboxes:
+ raise imap4.MailboxCollision, new
+
+ for (old, new) in inferiors:
+ self.mailboxes[new] = self.mailboxes[old]
+ del self.mailboxes[old]
+
+ def _inferiorNames(self, name):
+ inferiors = []
+ for infname in self.mailboxes.keys():
+ if infname.startswith(name):
+ inferiors.append(infname)
+ return inferiors
+
+ def isSubscribed(self, name):
+ return name.upper() in self.subscriptions
+
+ def subscribe(self, name):
+ name = name.upper()
+ if name not in self.subscriptions:
+ self._index.addSubscription(name)
+
+ def unsubscribe(self, name):
+ name = name.upper()
+ if name not in self.subscriptions:
+ raise imap4.MailboxException, "Not currently subscribed to " + name
+ self._index.removeSubscription(name)
+
+ def listMailboxes(self, ref, wildcard):
+ ref = self._inferiorNames(ref.upper())
+ wildcard = imap4.wildcardToRegexp(wildcard, '/')
+ return [(i, self.mailboxes[i]) for i in ref if wildcard.match(i)]
+
+ ##
+ ## INamespacePresenter
+ ##
+
+ def getPersonalNamespaces(self):
+ return [["", "/"]]
+
+ def getSharedNamespaces(self):
+ return None
+
+ def getOtherNamespaces(self):
+ return None
+
+#######################################
+# Soledad Message, MessageCollection
+# and Mailbox
+#######################################
+
+FLAGS_INDEX = 'flags'
+SEEN_INDEX = 'seen'
+INDEXES = {FLAGS_INDEX: ['flags'],
+ SEEN_INDEX: ['bool(seen)'],
+}
+
+
+class Message(u1db.Document):
+ """A rfc822 message item."""
+ # XXX TODO use email module
+
+ def _get_subject(self):
+ """Get the message title."""
+ return self.content.get('subject')
+
+ def _set_subject(self, subject):
+ """Set the message title."""
+ self.content['subject'] = subject
+
+ subject = property(_get_subject, _set_subject,
+ doc="Subject of the message.")
+
+ def _get_seen(self):
+ """Get the seen status of the message."""
+ return self.content.get('seen', False)
+
+ 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 _set_flags(self, flags):
+ """Set flags associated with the message."""
+ self.content['flags'] = list(set(flags))
+
+ flags = property(_get_flags, _set_flags, doc="Message flags.")
+
+EMPTY_MSG = {
+ "subject": "",
+ "seen": False,
+ "flags": [],
+ "mailbox": "",
+}
+get_empty_msg = lambda: copy.deepcopy(EMPTY_MSG)
+
+
+class MessageCollection(object):
+ """
+ A collection of messages
+ """
+
+ def __init__(self, mbox=None, db=None):
+ assert mbox
+ self.db = db
+ self.initialize_db()
+
+ 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
+
+ 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, subject=None, flags=None):
+ """Create a new message document."""
+ 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)
+
+ def get_all(self):
+ """Get all messages"""
+ return self.db.get_from_index(SEEN_INDEX, "*")
+
+ def get_unseen(self):
+ """Get only unseen messages"""
+ return self.db.get_from_index(SEEN_INDEX, "0")
+
+ def count(self):
+ return len(self.get_all())
+
+
+class SoledadMailbox:
+ """
+ A Soledad-backed IMAP mailbox
+ """
+
+ implements(imap4.IMailboxInfo, imap4.IMailbox, imap4.ICloseableMailbox)
+
+ flags = ('\\Seen', '\\Answered', '\\Flagged',
+ '\\Deleted', '\\Draft', '\\Recent', 'List')
+
+ #messages = []
+ messages = None
+ mUID = 0
+ rw = 1
+ closed = False
+
+ def __init__(self, mbox, soledad=None):
+ # XXX sanity check:
+ #soledad is not None and isinstance(SQLCipherDatabase, soldad._db)
+ 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 getFlags(self):
+ return self.messages.db.get_index_keys(FLAGS_INDEX)
+
+ def getUIDValidity(self):
+ return 42
+
+ def getUIDNext(self):
+ return self.messages.count() + 1
+
+ def getMessageCount(self):
+ return self.messages.count()
+
+ def getUnseenCount(self):
+ return len(self.messages.get_unseen())
+
+ def getRecentCount(self):
+ # XXX
+ return 3
+
+ def isWriteable(self):
+ return self.rw
+
+ def destroy(self):
+ pass
+
+ def getHierarchicalDelimiter(self):
+ return '/'
+
+ def requestStatus(self, names):
+ r = {}
+ if 'MESSAGES' in names:
+ r['MESSAGES'] = self.getMessageCount()
+ if 'RECENT' in names:
+ r['RECENT'] = self.getRecentCount()
+ if 'UIDNEXT' in names:
+ r['UIDNEXT'] = self.getMessageCount() + 1
+ if 'UIDVALIDITY' in names:
+ r['UIDVALIDITY'] = self.getUID()
+ if 'UNSEEN' in names:
+ r['UNSEEN'] = self.getUnseenCount()
+ return defer.succeed(r)
+
+ def addMessage(self, message, flags, date=None):
+ # self.messages.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
+ 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)
+
+ def expunge(self):
+ """deletes all messages flagged \\Deleted"""
+ # XXX FIXME!
+ 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
diff --git a/src/leap/mail/imap/tests/__init__.py b/src/leap/mail/imap/tests/__init__.py
new file mode 100644
index 0000000..9a4c663
--- /dev/null
+++ b/src/leap/mail/imap/tests/__init__.py
@@ -0,0 +1,232 @@
+#-*- encoding: utf-8 -*-
+"""
+leap/email/imap/tests/__init__.py
+----------------------------------
+Module intialization file for leap.mx.tests, a module containing unittesting
+code, using twisted.trial, for testing leap_mx.
+
+@authors: Kali Kaneko, <kali@leap.se>
+@license: GPLv3, see included LICENSE file
+@copyright: © 2013 Kali Kaneko, see COPYLEFT file
+"""
+
+__all__ = ['test_imap']
+
+
+def run():
+ """xxx fill me in"""
+ pass
+
+import u1db
+
+from leap.common.testing.basetest import BaseLeapTest
+
+from leap.soledad import Soledad
+from leap.soledad.util import GPGWrapper
+from leap.soledad.backends.leap_backend import LeapDocument
+
+
+#-----------------------------------------------------------------------------
+# Some tests inherit from BaseSoledadTest in order to have a working Soledad
+# instance in each test.
+#-----------------------------------------------------------------------------
+
+class BaseSoledadIMAPTest(BaseLeapTest):
+ """
+ Instantiates GPG and Soledad for usage in LeapIMAPServer tests.
+ Copied from BaseSoledadTest, but moving setup to classmethod
+ """
+
+ def setUp(self):
+ # config info
+ self.gnupg_home = "%s/gnupg" % self.tempdir
+ self.db1_file = "%s/db1.u1db" % self.tempdir
+ self.db2_file = "%s/db2.u1db" % self.tempdir
+ self.email = 'leap@leap.se'
+ # open test dbs
+ self._db1 = u1db.open(self.db1_file, create=True,
+ 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,
+ 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()
+ self._soledad._init_db()
+
+ def tearDown(self):
+ self._db1.close()
+ self._db2.close()
+ self._soledad.close()
+
+
+# Key material for testing
+KEY_FINGERPRINT = "E36E738D69173C13D709E44F2F455E2824D18DDF"
+PUBLIC_KEY = """
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v1.4.10 (GNU/Linux)
+
+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
+=MuOY
+-----END PGP PUBLIC KEY BLOCK-----
+"""
+PRIVATE_KEY = """
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Version: GnuPG v1.4.10 (GNU/Linux)
+
+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
+=JTFu
+-----END PGP PRIVATE KEY BLOCK-----
+"""
diff --git a/src/leap/mail/imap/tests/imapclient.py b/src/leap/mail/imap/tests/imapclient.py
new file mode 100755
index 0000000..027396c
--- /dev/null
+++ b/src/leap/mail/imap/tests/imapclient.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python
+
+# Copyright (c) Twisted Matrix Laboratories.
+# See LICENSE for details.
+
+
+"""
+Simple IMAP4 client which connects to our custome
+IMAP4 server: imapserver.py.
+"""
+
+import sys
+
+from twisted.internet import protocol
+from twisted.internet import defer
+from twisted.internet import stdio
+from twisted.mail import imap4
+from twisted.protocols import basic
+from twisted.python import util
+from twisted.python import log
+
+
+class TrivialPrompter(basic.LineReceiver):
+ #from os import linesep as delimiter
+
+ promptDeferred = None
+
+ def prompt(self, msg):
+ assert self.promptDeferred is None
+ self.display(msg)
+ self.promptDeferred = defer.Deferred()
+ return self.promptDeferred
+
+ def display(self, msg):
+ self.transport.write(msg)
+
+ def lineReceived(self, line):
+ if self.promptDeferred is None:
+ return
+ d, self.promptDeferred = self.promptDeferred, None
+ d.callback(line)
+
+
+class SimpleIMAP4Client(imap4.IMAP4Client):
+ """
+ Add callbacks when the client receives greeting messages from
+ an IMAP server.
+ """
+ greetDeferred = None
+
+ def serverGreeting(self, caps):
+ self.serverCapabilities = caps
+ if self.greetDeferred is not None:
+ d, self.greetDeferred = self.greetDeferred, None
+ d.callback(self)
+
+
+class SimpleIMAP4ClientFactory(protocol.ClientFactory):
+ usedUp = False
+ protocol = SimpleIMAP4Client
+
+ def __init__(self, username, onConn):
+ self.username = username
+ self.onConn = onConn
+
+ def buildProtocol(self, addr):
+ assert not self.usedUp
+ self.usedUp = True
+
+ p = self.protocol()
+ p.factory = self
+ p.greetDeferred = self.onConn
+
+ p.registerAuthenticator(imap4.PLAINAuthenticator(self.username))
+ p.registerAuthenticator(imap4.LOGINAuthenticator(self.username))
+ p.registerAuthenticator(
+ imap4.CramMD5ClientAuthenticator(self.username))
+
+ return p
+
+ def clientConnectionFailed(self, connector, reason):
+ d, self.onConn = self.onConn, None
+ d.errback(reason)
+
+
+def cbServerGreeting(proto, username, password):
+ """
+ Initial callback - invoked after the server sends us its greet message.
+ """
+ # Hook up stdio
+ tp = TrivialPrompter()
+ stdio.StandardIO(tp)
+
+ # And make it easily accessible
+ proto.prompt = tp.prompt
+ proto.display = tp.display
+
+ # Try to authenticate securely
+ return proto.authenticate(
+ password).addCallback(
+ cbAuthentication, proto).addErrback(
+ ebAuthentication, proto, username, password)
+
+
+def ebConnection(reason):
+ """
+ Fallback error-handler. If anything goes wrong, log it and quit.
+ """
+ log.startLogging(sys.stdout)
+ log.err(reason)
+ return reason
+
+
+def cbAuthentication(result, proto):
+ """
+ Callback after authentication has succeeded.
+ List a bunch of mailboxes.
+ """
+ return proto.list("", "*"
+ ).addCallback(cbMailboxList, proto
+ )
+
+
+def ebAuthentication(failure, proto, username, password):
+ """
+ Errback invoked when authentication fails.
+ If it failed because no SASL mechanisms match, offer the user the choice
+ of logging in insecurely.
+ If you are trying to connect to your Gmail account, you will be here!
+ """
+ failure.trap(imap4.NoSupportedAuthentication)
+ return proto.prompt(
+ "No secure authentication available. Login insecurely? (y/N) "
+ ).addCallback(cbInsecureLogin, proto, username, password
+ )
+
+
+def cbInsecureLogin(result, proto, username, password):
+ """
+ Callback for "insecure-login" prompt.
+ """
+ if result.lower() == "y":
+ # If they said yes, do it.
+ return proto.login(username, password
+ ).addCallback(cbAuthentication, proto
+ )
+ return defer.fail(Exception("Login failed for security reasons."))
+
+
+def cbMailboxList(result, proto):
+ """
+ Callback invoked when a list of mailboxes has been retrieved.
+ """
+ result = [e[2] for e in result]
+ s = '\n'.join(
+ ['%d. %s' % (n + 1, m) for (n, m) in zip(range(len(result)), result)])
+ if not s:
+ return defer.fail(Exception("No mailboxes exist on server!"))
+ return proto.prompt(s + "\nWhich mailbox? [1] "
+ ).addCallback(cbPickMailbox, proto, result
+ )
+
+
+def cbPickMailbox(result, proto, mboxes):
+ """
+ When the user selects a mailbox, "examine" it.
+ """
+ mbox = mboxes[int(result or '1') - 1]
+ return proto.status(mbox, 'MESSAGES', 'UNSEEN'
+ ).addCallback(cbMboxStatus, proto)
+
+
+def cbMboxStatus(result, proto):
+ print "You have %s messages (%s unseen)!" % (
+ result['MESSAGES'], result['UNSEEN'])
+ return proto.logout()
+
+
+def cbClose(result):
+ """
+ Close the connection when we finish everything.
+ """
+ from twisted.internet import reactor
+ reactor.stop()
+
+
+def main():
+ hostname = raw_input('IMAP4 Server Hostname: ')
+ port = raw_input('IMAP4 Server Port (the default is 143): ')
+ username = raw_input('IMAP4 Username: ')
+ password = util.getPassword('IMAP4 Password: ')
+
+ onConn = defer.Deferred(
+ ).addCallback(cbServerGreeting, username, password
+ ).addErrback(ebConnection
+ ).addBoth(cbClose)
+
+ factory = SimpleIMAP4ClientFactory(username, onConn)
+
+ from twisted.internet import reactor
+ conn = reactor.connectTCP(hostname, int(port), factory)
+ reactor.run()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/leap/mail/imap/tests/rfc822.message b/src/leap/mail/imap/tests/rfc822.message
new file mode 100644
index 0000000..ee97ab9
--- /dev/null
+++ b/src/leap/mail/imap/tests/rfc822.message
@@ -0,0 +1,86 @@
+Return-Path: <twisted-commits-admin@twistedmatrix.com>
+Delivered-To: exarkun@meson.dyndns.org
+Received: from localhost [127.0.0.1]
+ by localhost with POP3 (fetchmail-6.2.1)
+ for exarkun@localhost (single-drop); Thu, 20 Mar 2003 14:50:20 -0500 (EST)
+Received: from pyramid.twistedmatrix.com (adsl-64-123-27-105.dsl.austtx.swbell.net [64.123.27.105])
+ by intarweb.us (Postfix) with ESMTP id 4A4A513EA4
+ for <exarkun@meson.dyndns.org>; Thu, 20 Mar 2003 14:49:27 -0500 (EST)
+Received: from localhost ([127.0.0.1] helo=pyramid.twistedmatrix.com)
+ by pyramid.twistedmatrix.com with esmtp (Exim 3.35 #1 (Debian))
+ id 18w648-0007Vl-00; Thu, 20 Mar 2003 13:51:04 -0600
+Received: from acapnotic by pyramid.twistedmatrix.com with local (Exim 3.35 #1 (Debian))
+ id 18w63j-0007VK-00
+ for <twisted-commits@twistedmatrix.com>; Thu, 20 Mar 2003 13:50:39 -0600
+To: twisted-commits@twistedmatrix.com
+From: etrepum CVS <etrepum@twistedmatrix.com>
+Reply-To: twisted-python@twistedmatrix.com
+X-Mailer: CVSToys
+Message-Id: <E18w63j-0007VK-00@pyramid.twistedmatrix.com>
+Subject: [Twisted-commits] rebuild now works on python versions from 2.2.0 and up.
+Sender: twisted-commits-admin@twistedmatrix.com
+Errors-To: twisted-commits-admin@twistedmatrix.com
+X-BeenThere: twisted-commits@twistedmatrix.com
+X-Mailman-Version: 2.0.11
+Precedence: bulk
+List-Help: <mailto:twisted-commits-request@twistedmatrix.com?subject=help>
+List-Post: <mailto:twisted-commits@twistedmatrix.com>
+List-Subscribe: <http://twistedmatrix.com/cgi-bin/mailman/listinfo/twisted-commits>,
+ <mailto:twisted-commits-request@twistedmatrix.com?subject=subscribe>
+List-Id: <twisted-commits.twistedmatrix.com>
+List-Unsubscribe: <http://twistedmatrix.com/cgi-bin/mailman/listinfo/twisted-commits>,
+ <mailto:twisted-commits-request@twistedmatrix.com?subject=unsubscribe>
+List-Archive: <http://twistedmatrix.com/pipermail/twisted-commits/>
+Date: Thu, 20 Mar 2003 13:50:39 -0600
+
+Modified files:
+Twisted/twisted/python/rebuild.py 1.19 1.20
+
+Log message:
+rebuild now works on python versions from 2.2.0 and up.
+
+
+ViewCVS links:
+http://twistedmatrix.com/users/jh.twistd/viewcvs/cgi/viewcvs.cgi/twisted/python/rebuild.py.diff?r1=text&tr1=1.19&r2=text&tr2=1.20&cvsroot=Twisted
+
+Index: Twisted/twisted/python/rebuild.py
+diff -u Twisted/twisted/python/rebuild.py:1.19 Twisted/twisted/python/rebuild.py:1.20
+--- Twisted/twisted/python/rebuild.py:1.19 Fri Jan 17 13:50:49 2003
++++ Twisted/twisted/python/rebuild.py Thu Mar 20 11:50:08 2003
+@@ -206,15 +206,27 @@
+ clazz.__dict__.clear()
+ clazz.__getattr__ = __getattr__
+ clazz.__module__ = module.__name__
++ if newclasses:
++ import gc
++ if (2, 2, 0) <= sys.version_info[:3] < (2, 2, 2):
++ hasBrokenRebuild = 1
++ gc_objects = gc.get_objects()
++ else:
++ hasBrokenRebuild = 0
+ for nclass in newclasses:
+ ga = getattr(module, nclass.__name__)
+ if ga is nclass:
+ log.msg("WARNING: new-class %s not replaced by reload!" % reflect.qual(nclass))
+ else:
+- import gc
+- for r in gc.get_referrers(nclass):
+- if isinstance(r, nclass):
++ if hasBrokenRebuild:
++ for r in gc_objects:
++ if not getattr(r, '__class__', None) is nclass:
++ continue
+ r.__class__ = ga
++ else:
++ for r in gc.get_referrers(nclass):
++ if getattr(r, '__class__', None) is nclass:
++ r.__class__ = ga
+ if doLog:
+ log.msg('')
+ log.msg(' (fixing %s): ' % str(module.__name__))
+
+
+_______________________________________________
+Twisted-commits mailing list
+Twisted-commits@twistedmatrix.com
+http://twistedmatrix.com/cgi-bin/mailman/listinfo/twisted-commits
diff --git a/src/leap/mail/imap/tests/test_imap.py b/src/leap/mail/imap/tests/test_imap.py
new file mode 100644
index 0000000..6792e4b
--- /dev/null
+++ b/src/leap/mail/imap/tests/test_imap.py
@@ -0,0 +1,957 @@
+#-*- encoding: utf-8 -*-
+"""
+leap/email/imap/tests/test_imap.py
+----------------------------------
+Test case for leap.email.imap.server
+
+@authors: Kali Kaneko, <kali@leap.se>
+@license: GPLv3, see included LICENSE file
+@copyright: © 2013 Kali Kaneko, see COPYLEFT file
+"""
+
+try:
+ from cStringIO import StringIO
+except ImportError:
+ from StringIO import StringIO
+
+import codecs
+import locale
+import os
+import types
+import tempfile
+import shutil
+
+
+from zope.interface import implements
+
+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.trial import unittest
+from twisted.python import util, log
+from twisted.python import failure
+
+from twisted import cred
+import twisted.cred.error
+import twisted.cred.checkers
+import twisted.cred.credentials
+import twisted.cred.portal
+
+from twisted.test.proto_helpers import StringTransport, StringTransportWithDisconnection
+
+
+import u1db
+
+from leap.common.testing.basetest import BaseLeapTest
+from leap.mail.imap.server import SoledadMailbox
+from leap.mail.imap.tests import PUBLIC_KEY
+from leap.mail.imap.tests import PRIVATE_KEY
+
+from leap.soledad import Soledad
+from leap.soledad.util import GPGWrapper
+from leap.soledad.backends.leap_backend import LeapDocument
+
+
+def strip(f):
+ return lambda result, f=f: f()
+
+
+def sortNest(l):
+ l = l[:]
+ l.sort()
+ for i in range(len(l)):
+ if isinstance(l[i], types.ListType):
+ l[i] = sortNest(l[i])
+ elif isinstance(l[i], types.TupleType):
+ l[i] = tuple(sortNest(list(l[i])))
+ return l
+
+
+def initialize_soledad(email, gnupg_home, tempdir):
+ """
+ initializes soledad by hand
+ """
+ _soledad = Soledad(email, gnupg_home=gnupg_home,
+ initialize=False,
+ prefix=tempdir)
+ _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._init_db()
+ return _soledad
+
+
+##########################################
+# account, simpleserver
+##########################################
+
+
+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
+
+
+class SimpleLEAPServer(imap4.IMAP4Server):
+ def __init__(self, *args, **kw):
+ imap4.IMAP4Server.__init__(self, *args, **kw)
+ realm = TestRealm()
+ realm.theAccount = SoledadBackedAccount('testuser')
+ # XXX soledadInstance here?
+
+ portal = cred.portal.Portal(realm)
+ c = cred.checkers.InMemoryUsernamePasswordDatabaseDontUse()
+ self.checker = c
+ self.portal = portal
+ portal.registerChecker(c)
+ self.timeoutTest = False
+
+ def lineReceived(self, line):
+ if self.timeoutTest:
+ #Do not send a respones
+ return
+
+ imap4.IMAP4Server.lineReceived(self, line)
+
+ _username = 'testuser'
+ _password = 'password-test'
+
+ def authenticateLogin(self, username, password):
+ if username == self._username and password == self._password:
+ return imap4.IAccount, self.theAccount, lambda: None
+ raise cred.error.UnauthorizedLogin()
+
+
+class TestRealm:
+ theAccount = None
+
+ def requestAvatar(self, avatarId, mind, *interfaces):
+ return imap4.IAccount, self.theAccount, lambda: None
+
+######################
+# Test LEAP Server
+######################
+
+
+class SimpleClient(imap4.IMAP4Client):
+
+ def __init__(self, deferred, contextFactory=None):
+ imap4.IMAP4Client.__init__(self, contextFactory)
+ self.deferred = deferred
+ self.events = []
+
+ def serverGreeting(self, caps):
+ self.deferred.callback(None)
+
+ def modeChanged(self, writeable):
+ self.events.append(['modeChanged', writeable])
+ self.transport.loseConnection()
+
+ def flagsChanged(self, newFlags):
+ self.events.append(['flagsChanged', newFlags])
+ self.transport.loseConnection()
+
+ def newMessages(self, exists, recent):
+ self.events.append(['newMessages', exists, recent])
+ self.transport.loseConnection()
+
+
+class IMAP4HelperMixin(BaseLeapTest):
+
+ serverCTX = None
+ clientCTX = None
+
+ @classmethod
+ def setUpClass(cls):
+ cls.old_path = os.environ['PATH']
+ cls.old_home = os.environ['HOME']
+ cls.tempdir = tempfile.mkdtemp(prefix="leap_tests-")
+ cls.home = cls.tempdir
+ bin_tdir = os.path.join(
+ cls.tempdir,
+ 'bin')
+ os.environ["PATH"] = bin_tdir
+ os.environ["HOME"] = cls.tempdir
+
+ # Soledad: config info
+ cls.gnupg_home = "%s/gnupg" % cls.tempdir
+ cls.email = 'leap@leap.se'
+ #cls.db1_file = "%s/db1.u1db" % cls.tempdir
+ #cls.db2_file = "%s/db2.u1db" % cls.tempdir
+ # open test dbs
+ #cls._db1 = u1db.open(cls.db1_file, create=True,
+ #document_factory=LeapDocument)
+ #cls._db2 = u1db.open(cls.db2_file, create=True,
+ #document_factory=LeapDocument)
+
+ # initialize soledad by hand so we can control keys
+ cls._soledad = initialize_soledad(
+ cls.email,
+ cls.gnupg_home,
+ cls.tempdir)
+
+ cls.sm = SoledadMailbox(soledad=cls._soledad)
+
+ @classmethod
+ def tearDownClass(cls):
+ #cls._db1.close()
+ #cls._db2.close()
+ cls._soledad.close()
+
+ os.environ["PATH"] = cls.old_path
+ os.environ["HOME"] = cls.old_home
+ # safety check
+ assert cls.tempdir.startswith('/tmp/leap_tests-')
+ shutil.rmtree(cls.tempdir)
+
+ def setUp(self):
+ d = defer.Deferred()
+ self.server = SimpleLEAPServer(contextFactory=self.serverCTX)
+ self.client = SimpleClient(d, contextFactory=self.clientCTX)
+ self.connected = d
+
+ theAccount = SoledadBackedAccount('testuser')
+ theAccount.soledadInstance = self._soledad
+
+ # XXX used for something???
+ #theAccount.mboxType = SoledadMailbox
+ SimpleLEAPServer.theAccount = theAccount
+
+ def tearDown(self):
+ self.delete_all_docs()
+ 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")
+ # XXX should change Flags too
+ self._soledad.messages.add_msg(subject="test4")
+
+ def delete_all_docs(self):
+ self.server.theAccount.messages.deleteAllDocs()
+
+ def _cbStopClient(self, ignore):
+ self.client.transport.loseConnection()
+
+ def _ebGeneral(self, failure):
+ self.client.transport.loseConnection()
+ self.server.transport.loseConnection()
+ log.err(failure, "Problem with %r" % (self.function,))
+
+ def loopback(self):
+ 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
+
+ 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):
+ """
+ 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.
+ """
+ SimpleLEAPServer.theAccount.addMailbox('test-mailbox')
+ 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')
+ 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 = 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})
+
+ def testCreate(self):
+ succeed = ('testbox', 'test/box', 'test/', 'test/box/box', 'INBOX')
+ fail = ('testbox', 'test/box')
+
+ def cb():
+ self.result.append(1)
+
+ def eb(failure):
+ self.result.append(0)
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def create():
+ for name in succeed + fail:
+ d = self.client.create(name)
+ d.addCallback(strip(cb)).addErrback(eb)
+ d.addCallbacks(self._cbStopClient, self._ebGeneral)
+
+ self.result = []
+ d1 = self.connected.addCallback(strip(login)).addCallback(
+ strip(create))
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ return d.addCallback(self._cbTestCreate, succeed, fail)
+
+ 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.sort()
+ answers.sort()
+ self.assertEqual(mbox, [a.upper() for a in answers])
+
+ def testDelete(self):
+ SimpleLEAPServer.theAccount.addMailbox('delete/me')
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def delete():
+ return self.client.delete('delete/me')
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(delete), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _:
+ self.assertEqual(SimpleLEAPServer.theAccount.mailboxes.keys(), []))
+ return d
+
+ def testIllegalInboxDelete(self):
+ self.stashed = None
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def delete():
+ return self.client.delete('inbox')
+
+ def stash(result):
+ self.stashed = result
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(delete), self._ebGeneral)
+ d1.addBoth(stash)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: self.failUnless(isinstance(self.stashed,
+ failure.Failure)))
+ return d
+
+ def testNonExistentDelete(self):
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def delete():
+ return self.client.delete('delete/me')
+
+ def deleteFailed(failure):
+ self.failure = failure
+
+ self.failure = None
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallback(strip(delete)).addErrback(deleteFailed)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: self.assertEqual(str(self.failure.value),
+ 'No such mailbox'))
+ return d
+
+ def testIllegalDelete(self):
+ m = SoledadMailbox()
+ m.flags = (r'\Noselect',)
+ SimpleLEAPServer.theAccount.addMailbox('delete', m)
+ SimpleLEAPServer.theAccount.addMailbox('delete/me')
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def delete():
+ return self.client.delete('delete')
+
+ def deleteFailed(failure):
+ self.failure = failure
+
+ self.failure = None
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallback(strip(delete)).addErrback(deleteFailed)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ expected = ("Hierarchically inferior mailboxes exist "
+ "and \\Noselect is set")
+ d.addCallback(lambda _:
+ self.assertEqual(str(self.failure.value), expected))
+ return d
+
+ def testRename(self):
+ SimpleLEAPServer.theAccount.addMailbox('oldmbox')
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def rename():
+ return self.client.rename('oldmbox', 'newname')
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(rename), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _:
+ self.assertEqual(
+ SimpleLEAPServer.theAccount.mailboxes.keys(),
+ ['NEWNAME']))
+ return d
+
+ def testIllegalInboxRename(self):
+ self.stashed = None
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def rename():
+ return self.client.rename('inbox', 'frotz')
+
+ def stash(stuff):
+ self.stashed = stuff
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(rename), self._ebGeneral)
+ d1.addBoth(stash)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _:
+ self.failUnless(isinstance(
+ self.stashed, failure.Failure)))
+ return d
+
+ def testHierarchicalRename(self):
+ SimpleLEAPServer.theAccount.create('oldmbox/m1')
+ SimpleLEAPServer.theAccount.create('oldmbox/m2')
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def rename():
+ return self.client.rename('oldmbox', 'newname')
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(rename), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ return d.addCallback(self._cbTestHierarchicalRename)
+
+ def _cbTestHierarchicalRename(self, ignored):
+ mboxes = SimpleLEAPServer.theAccount.mailboxes.keys()
+ expected = ['newname', 'newname/m1', 'newname/m2']
+ mboxes.sort()
+ self.assertEqual(mboxes, [s.upper() for s in expected])
+
+ def testSubscribe(self):
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def subscribe():
+ return self.client.subscribe('this/mbox')
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(subscribe), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _:
+ self.assertEqual(SimpleLEAPServer.theAccount.subscriptions,
+ ['THIS/MBOX']))
+ return d
+
+ def testUnsubscribe(self):
+ SimpleLEAPServer.theAccount.subscriptions = ['THIS/MBOX', 'THAT/MBOX']
+ def login():
+ return self.client.login('testuser', 'password-test')
+ def unsubscribe():
+ return self.client.unsubscribe('this/mbox')
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(unsubscribe), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _:
+ self.assertEqual(SimpleLEAPServer.theAccount.subscriptions,
+ ['THAT/MBOX']))
+ return d
+
+ def _listSetup(self, f):
+ SimpleLEAPServer.theAccount.addMailbox('root/subthing')
+ SimpleLEAPServer.theAccount.addMailbox('root/another-thing')
+ SimpleLEAPServer.theAccount.addMailbox('non-root/subthing')
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def listed(answers):
+ self.listed = answers
+
+ self.listed = None
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(f), self._ebGeneral)
+ d1.addCallbacks(listed, self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ return defer.gatherResults([d1, d2]).addCallback(lambda _: self.listed)
+
+ def testList(self):
+ 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")
+ ])
+ ))
+ return d
+
+ def testLSub(self):
+ SimpleLEAPServer.theAccount.subscribe('ROOT/SUBTHING')
+
+ def lsub():
+ return self.client.lsub('root', '%')
+ d = self._listSetup(lsub)
+ d.addCallback(self.assertEqual,
+ [(SoledadMailbox.flags, "/", "ROOT/SUBTHING")])
+ return d
+
+ def testStatus(self):
+ SimpleLEAPServer.theAccount.addMailbox('root/subthing')
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def status():
+ return self.client.status(
+ 'root/subthing', 'MESSAGES', 'UIDNEXT', 'UNSEEN')
+
+ def statused(result):
+ self.statused = result
+
+ self.statused = None
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(status), self._ebGeneral)
+ d1.addCallbacks(statused, self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: self.assertEqual(
+ self.statused,
+ {'MESSAGES': 9, 'UIDNEXT': '10', 'UNSEEN': 4}
+ ))
+ return d
+
+ def testFailedStatus(self):
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def status():
+ return self.client.status(
+ 'root/nonexistent', 'MESSAGES', 'UIDNEXT', 'UNSEEN')
+
+ def statused(result):
+ self.statused = result
+
+ def failed(failure):
+ self.failure = failure
+
+ self.statused = self.failure = None
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(status), self._ebGeneral)
+ d1.addCallbacks(statused, failed)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ return defer.gatherResults([d1, d2]).addCallback(
+ self._cbTestFailedStatus)
+
+ def _cbTestFailedStatus(self, ignored):
+ self.assertEqual(
+ self.statused, None
+ )
+ self.assertEqual(
+ self.failure.value.args,
+ ('Could not open mailbox',)
+ )
+
+ def testFullAppend(self):
+ infile = util.sibpath(__file__, 'rfc822.message')
+ message = open(infile)
+ SimpleLEAPServer.theAccount.addMailbox('root/subthing')
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def append():
+ return self.client.append(
+ 'root/subthing',
+ message,
+ ('\\SEEN', '\\DELETED'),
+ 'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)',
+ )
+
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(append), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ return d.addCallback(self._cbTestFullAppend, infile)
+
+ def _cbTestFullAppend(self, ignored, infile):
+ mb = SimpleLEAPServer.theAccount.mailboxes['ROOT/SUBTHING']
+ self.assertEqual(1, len(mb.messages))
+ 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())
+
+ def testPartialAppend(self):
+ infile = util.sibpath(__file__, 'rfc822.message')
+ message = open(infile)
+ SimpleLEAPServer.theAccount.addMailbox('PARTIAL/SUBTHING')
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def append():
+ message = file(infile)
+ return self.client.sendCommand(
+ imap4.Command(
+ 'APPEND',
+ 'PARTIAL/SUBTHING (\\SEEN) "Right now" {%d}' % os.path.getsize(infile),
+ (), self.client._IMAP4Client__cbContinueAppend, message
+ )
+ )
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(append), self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ return d.addCallback(self._cbTestPartialAppend, infile)
+
+ def _cbTestPartialAppend(self, ignored, infile):
+ mb = SimpleLEAPServer.theAccount.mailboxes['PARTIAL/SUBTHING']
+ self.assertEqual(1, len(mb.messages))
+ self.assertEqual(
+ (['\\SEEN'], 'Right now', 0),
+ mb.messages[0][1:]
+ )
+ self.assertEqual(open(infile).read(), mb.messages[0][0].getvalue())
+
+ def testCheck(self):
+ SimpleLEAPServer.theAccount.addMailbox('root/subthing')
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def select():
+ return self.client.select('root/subthing')
+
+ def check():
+ return self.client.check()
+
+ d = self.connected.addCallback(strip(login))
+ d.addCallbacks(strip(select), self._ebGeneral)
+ d.addCallbacks(strip(check), self._ebGeneral)
+ d.addCallbacks(self._cbStopClient, self._ebGeneral)
+ return self.loopback()
+
+ # 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)
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def select():
+ return self.client.select('mailbox')
+
+ def close():
+ return self.client.close()
+
+ d = self.connected.addCallback(strip(login))
+ d.addCallbacks(strip(select), self._ebGeneral)
+ d.addCallbacks(strip(close), self._ebGeneral)
+ d.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ return defer.gatherResults([d, d2]).addCallback(self._cbTestClose, m)
+
+ def _cbTestClose(self, ignored, m):
+ self.assertEqual(len(m.messages), 1)
+ self.assertEqual(m.messages[0],
+ ('Message 2', ('AnotherFlag',), None, 1))
+ 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)
+
+ def login():
+ return self.client.login('testuser', 'password-test')
+
+ def select():
+ return self.client.select('mailbox')
+
+ def expunge():
+ return self.client.expunge()
+
+ def expunged(results):
+ self.failIf(self.server.mbox is None)
+ self.results = results
+
+ self.results = None
+ d1 = self.connected.addCallback(strip(login))
+ d1.addCallbacks(strip(select), self._ebGeneral)
+ d1.addCallbacks(strip(expunge), self._ebGeneral)
+ d1.addCallbacks(expunged, self._ebGeneral)
+ d1.addCallbacks(self._cbStopClient, self._ebGeneral)
+ d2 = self.loopback()
+ d = defer.gatherResults([d1, d2])
+ return d.addCallback(self._cbTestExpunge, m)
+
+ 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])
+
+
+
+class IMAP4ServerSearchTestCase(IMAP4HelperMixin, unittest.TestCase):
+ """
+ Tests for the behavior of the search_* functions in L{imap4.IMAP4Server}.
+ """
+ pass
diff --git a/src/leap/mail/smtp/README.rst b/src/leap/mail/smtp/README.rst
new file mode 100644
index 0000000..2b2a118
--- /dev/null
+++ b/src/leap/mail/smtp/README.rst
@@ -0,0 +1,43 @@
+Leap SMTP Relay
+===============
+
+Outgoing mail workflow:
+
+ * LEAP client runs a thin SMTP proxy on the user's device, bound to
+ localhost.
+ * User's MUA is configured outgoing SMTP to localhost
+ * When SMTP proxy receives an email from MUA
+ * SMTP proxy queries Key Manager for the user's private key and public
+ keys of all recipients
+ * Message is signed by sender and encrypted to recipients.
+ * If recipient's key is missing, email goes out in cleartext (unless
+ user has configured option to send only encrypted email)
+ * Finally, message is relayed to provider's SMTP relay
+
+
+Dependencies
+------------
+
+Leap SMTP Relay depends on the following python libraries:
+
+ * Twisted 12.3.0 [1]
+ * zope.interface 4.0.3 [2]
+
+[1] http://pypi.python.org/pypi/Twisted/12.3.0
+[2] http://pypi.python.org/pypi/zope.interface/4.0.3
+
+
+How to run
+----------
+
+To launch the SMTP relay, run the following command:
+
+ twistd -y smtprelay.tac
+
+
+Running tests
+-------------
+
+Tests are run using Twisted's Trial API, like this:
+
+ trial leap.email.smtp.tests
diff --git a/src/leap/mail/smtp/__init__.py b/src/leap/mail/smtp/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/leap/mail/smtp/__init__.py
diff --git a/src/leap/mail/smtp/smtprelay.py b/src/leap/mail/smtp/smtprelay.py
new file mode 100644
index 0000000..6479873
--- /dev/null
+++ b/src/leap/mail/smtp/smtprelay.py
@@ -0,0 +1,246 @@
+"""
+LEAP SMTP encrypted relay.
+"""
+
+import re
+import gnupg
+from zope.interface import implements
+from StringIO import StringIO
+from twisted.mail import smtp
+from twisted.internet.protocol import ServerFactory
+from twisted.internet import reactor
+from twisted.internet import defer
+from twisted.application import internet, service
+from twisted.python import log
+from email.Header import Header
+from leap import soledad
+
+
+class SMTPInfoNotAvailable(Exception):
+ pass
+
+
+class SMTPFactory(ServerFactory):
+ """
+ Factory for an SMTP server with encrypted relaying capabilities.
+ """
+
+ def __init__(self, soledad, gpg=None):
+ self._soledad = soledad
+ self._gpg = gpg
+
+ def buildProtocol(self, addr):
+ "Return a protocol suitable for the job."
+ # TODO: use ESMTP here.
+ smtpProtocol = smtp.SMTP(SMTPDelivery(self._soledad, self._gpg))
+ smtpProtocol.factory = self
+ return smtpProtocol
+
+
+class SMTPDelivery(object):
+ """
+ Validate email addresses and handle message delivery.
+ """
+
+ implements(smtp.IMessageDelivery)
+
+ def __init__(self, soledad, gpg=None):
+ self._soledad = soledad
+ if gpg:
+ self._gpg = gpg
+ else:
+ self._gpg = GPGWrapper()
+
+ def receivedHeader(self, helo, origin, recipients):
+ myHostname, clientIP = helo
+ headerValue = "by %s from %s with ESMTP ; %s" % (
+ myHostname, clientIP, smtp.rfc822date())
+ # email.Header.Header used for automatic wrapping of long lines
+ return "Received: %s" % Header(headerValue)
+
+ def validateTo(self, user):
+ """Assert existence of and trust on recipient's GPG public key."""
+ # try to find recipient's public key
+ try:
+ # this will raise an exception if key is not found
+ trust = self._gpg.find_key(user.dest.addrstr)['trust']
+ # if key is not ultimatelly trusted, then the message will not
+ # be encrypted. So, we check for this below
+ #if trust != 'u':
+ # raise smtp.SMTPBadRcpt(user)
+ log.msg("Accepting mail for %s..." % user.dest)
+ return lambda: EncryptedMessage(user, soledad=self._soledad,
+ gpg=self._gpg)
+ except LookupError:
+ # if key was not found, check config to see if will send anyway.
+ if self.encrypted_only:
+ raise smtp.SMTPBadRcpt(user)
+ # TODO: send signal to cli/gui that this user's key was not found?
+ log.msg("Warning: will send an unencrypted message (because "
+ "encrypted_only' is set to False).")
+
+ def validateFrom(self, helo, originAddress):
+ # accept mail from anywhere. To reject an address, raise
+ # smtp.SMTPBadSender here.
+ return originAddress
+
+
+class EncryptedMessage():
+ """
+ Receive plaintext from client, encrypt it and send message to a
+ recipient.
+ """
+ implements(smtp.IMessage)
+
+ SMTP_HOSTNAME = "mail.leap.se"
+ SMTP_PORT = 25
+
+ def __init__(self, user, soledad, gpg=None):
+ self.user = user
+ self._soledad = soledad
+ self.fetchConfig()
+ self.lines = []
+ if gpg:
+ self._gpg = gpg
+ else:
+ self._gpg = GPGWrapper()
+
+ def lineReceived(self, line):
+ """Store email DATA lines as they arrive."""
+ self.lines.append(line)
+
+ def eomReceived(self):
+ """Encrypt and send message."""
+ log.msg("Message data complete.")
+ self.lines.append('') # add a trailing newline
+ self.parseMessage()
+ try:
+ self.encrypt()
+ return self.sendMessage()
+ except LookupError:
+ return None
+
+ def parseMessage(self):
+ """Separate message headers from body."""
+ sep = self.lines.index('')
+ self.headers = self.lines[:sep]
+ self.body = self.lines[sep + 1:]
+
+ def connectionLost(self):
+ log.msg("Connection lost unexpectedly!")
+ log.err()
+ # unexpected loss of connection; don't save
+ self.lines = []
+
+ def sendSuccess(self, r):
+ log.msg(r)
+
+ def sendError(self, e):
+ log.msg(e)
+ log.err()
+
+ def prepareHeader(self):
+ self.headers.insert(1, "From: %s" % self.user.orig.addrstr)
+ self.headers.insert(2, "To: %s" % self.user.dest.addrstr)
+ self.headers.append('')
+
+ def sendMessage(self):
+ self.prepareHeader()
+ msg = '\n'.join(self.headers + [self.cyphertext])
+ d = defer.Deferred()
+ factory = smtp.ESMTPSenderFactory(self.smtp_username,
+ self.smtp_password,
+ self.smtp_username,
+ self.user.dest.addrstr,
+ StringIO(msg),
+ d)
+ # the next call is TSL-powered!
+ reactor.connectTCP(self.SMTP_HOSTNAME, self.SMTP_PORT, factory)
+ d.addCallback(self.sendSuccess)
+ d.addErrback(self.sendError)
+ return d
+
+ def encrypt(self, always_trust=True):
+ # TODO: do not "always trust" here.
+ try:
+ fp = self._gpg.find_key(self.user.dest.addrstr)['fingerprint']
+ log.msg("Encrypting to %s" % fp)
+ self.cyphertext = str(
+ self._gpg.encrypt(
+ '\n'.join(self.body), [fp], always_trust=always_trust))
+ except LookupError:
+ if self.encrypted_only:
+ raise
+ log.msg("Warning: sending unencrypted mail (because "
+ "'encrypted_only' is set to False).")
+
+ # this will be replaced by some other mechanism of obtaining credentials
+ # for SMTP server.
+ def fetchConfig(self):
+ # TODO: Soledad/LEAP bootstrap should store the SMTP info on local db,
+ # so this relay can load it when it needs.
+ if not self._soledad:
+ # TODO: uncomment below exception when integration with Soledad is
+ # smooth.
+ #raise SMTPInfoNotAvailable()
+ # TODO: remove dummy settings below when soledad bootstrap is
+ # working.
+ self.smtp_host = ''
+ self.smtp_port = ''
+ self.smtp_username = ''
+ self.smtp_password = ''
+ self.encrypted_only = True
+ else:
+ self.smtp_config = self._soledad.get_doc('smtp_relay_config')
+ for confname in [
+ 'smtp_host', 'smtp_port', 'smtp_username',
+ 'smtp_password', 'encrypted_only',
+ ]:
+ setattr(self, confname, doc.content[confname])
+
+
+class GPGWrapper():
+ """
+ This is a temporary class for handling GPG requests, and should be
+ replaced by a more general class used throughout the project.
+ """
+
+ GNUPG_HOME = "~/.config/leap/gnupg"
+ GNUPG_BINARY = "/usr/bin/gpg" # TODO: change this based on OS
+
+ def __init__(self, gpghome=GNUPG_HOME, gpgbinary=GNUPG_BINARY):
+ self.gpg = gnupg.GPG(gnupghome=gpghome, gpgbinary=gpgbinary)
+
+ def find_key(self, email):
+ """
+ Find user's key based on their email.
+ """
+ for key in self.gpg.list_keys():
+ for uid in key['uids']:
+ if re.search(email, uid):
+ return key
+ raise LookupError("GnuPG public key for %s not found!" % email)
+
+ def encrypt(self, data, recipient, always_trust=True):
+ # TODO: do not 'always_trust'.
+ return self.gpg.encrypt(data, recipient, always_trust=always_trust)
+
+ def decrypt(self, data):
+ return self.gpg.decrypt(data)
+
+ def import_keys(self, data):
+ return self.gpg.import_keys(data)
+
+
+# service configuration
+port = 25
+user_email = 'user@leap.se' # TODO: replace for real mail from gui/cli
+
+# instantiate soledad for client app storage and sync
+s = soledad.Soledad(user_email)
+factory = SMTPFactory(s)
+
+# enable the use of this service with twistd
+application = service.Application("LEAP SMTP Relay")
+service = internet.TCPServer(port, factory)
+service.setServiceParent(application)
diff --git a/src/leap/mail/smtp/tests/185CA770.key b/src/leap/mail/smtp/tests/185CA770.key
new file mode 100644
index 0000000..587b416
--- /dev/null
+++ b/src/leap/mail/smtp/tests/185CA770.key
@@ -0,0 +1,79 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Version: GnuPG v1.4.10 (GNU/Linux)
+
+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
+=6HcJ
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/src/leap/mail/smtp/tests/185CA770.pub b/src/leap/mail/smtp/tests/185CA770.pub
new file mode 100644
index 0000000..38af19f
--- /dev/null
+++ b/src/leap/mail/smtp/tests/185CA770.pub
@@ -0,0 +1,52 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v1.4.10 (GNU/Linux)
+
+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
+q3iu
+=RChS
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/src/leap/mail/smtp/tests/__init__.py b/src/leap/mail/smtp/tests/__init__.py
new file mode 100644
index 0000000..d7b942a
--- /dev/null
+++ b/src/leap/mail/smtp/tests/__init__.py
@@ -0,0 +1,218 @@
+import os
+import shutil
+import tempfile
+
+from twisted.trial import unittest
+
+from leap.common.testing.basetest import BaseLeapTest
+from leap.mail.smtp.smtprelay import GPGWrapper
+
+
+class OpenPGPTestCase(unittest.TestCase, BaseLeapTest):
+
+ def setUp(self):
+ # mimic LeapBaseTest.setUpClass behaviour, because this is deprecated
+ # in Twisted: http://twistedmatrix.com/trac/ticket/1870
+ self.old_path = os.environ['PATH']
+ self.old_home = os.environ['HOME']
+ self.tempdir = tempfile.mkdtemp(prefix="leap_tests-")
+ self.home = self.tempdir
+ bin_tdir = os.path.join(
+ self.tempdir,
+ 'bin')
+ os.environ["PATH"] = bin_tdir
+ os.environ["HOME"] = self.tempdir
+ # setup our own stuff
+ self.gnupg_home = self.tempdir + '/gnupg'
+ os.mkdir(self.gnupg_home)
+ self.email = 'leap@leap.se'
+ self._gpg = GPGWrapper(gpghome=self.gnupg_home)
+
+ self.assertEqual(self._gpg.import_keys(PUBLIC_KEY).summary(),
+ '1 imported', "error importing public key")
+ self.assertEqual(self._gpg.import_keys(PRIVATE_KEY).summary(),
+ # note that gnupg does not return a successful import
+ # for private keys. Bug?
+ '0 imported', "error importing private key")
+
+ def tearDown(self):
+ # mimic LeapBaseTest.tearDownClass behaviour
+ os.environ["PATH"] = self.old_path
+ os.environ["HOME"] = self.old_home
+ # safety check
+ assert self.tempdir.startswith('/tmp/leap_tests-')
+ shutil.rmtree(self.tempdir)
+
+ def test_openpgp_encrypt_decrypt(self):
+ "Test if openpgp can encrypt and decrypt."
+ text = "simple raw text"
+ encrypted = str(self._gpg.encrypt(text, KEY_FINGERPRINT,
+ # TODO: handle always trust issue
+ always_trust=True))
+ self.assertNotEqual(text, encrypted, "failed encrypting text")
+ decrypted = str(self._gpg.decrypt(encrypted))
+ self.assertEqual(text, decrypted, "failed decrypting text")
+
+
+# Key material for testing
+KEY_FINGERPRINT = "E36E738D69173C13D709E44F2F455E2824D18DDF"
+PUBLIC_KEY = """
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v1.4.10 (GNU/Linux)
+
+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
+=MuOY
+-----END PGP PUBLIC KEY BLOCK-----
+"""
+PRIVATE_KEY = """
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+Version: GnuPG v1.4.10 (GNU/Linux)
+
+lQcYBFC9+dkBEADNRfwV23TWEoGc/x0wWH1P7PlXt8MnC2Z1kKaKKmfnglVrpOiz
+iLWoiU58sfZ0L5vHkzXHXCBf6Eiy/EtUIvdiWAn+yASJ1mk5jZTBKO/WMAHD8wTO
+zpMsFmWyg3xc4DkmFa9KQ5EVU0o/nqPeyQxNMQN7px5pPwrJtJFmPxnxm+aDkPYx
+irDmz/4DeDNqXliazGJKw7efqBdlwTHkl9Akw2gwy178pmsKwHHEMOBOFFvX61AT
+huKqHYmlCGSliwbrJppTG7jc1/ls3itrK+CWTg4txREkSpEVmfcASvw/ZqLbjgfs
+d/INMwXnR9U81O8+7LT6yw/ca4ppcFoJD7/XJbkRiML6+bJ4Dakiy6i727BzV17g
+wI1zqNvm5rAhtALKfACha6YO43aJzairO4II1wxVHvRDHZn2IuKDDephQ3Ii7/vb
+hUOf6XCSmchkAcpKXUOvbxm1yfB1LRa64mMc2RcZxf4mW7KQkulBsdV5QG2276lv
+U2UUy2IutXcGP5nXC+f6sJJGJeEToKJ57yiO/VWJFjKN8SvP+7AYsQSqINUuEf6H
+T5gCPCraGMkTUTPXrREvu7NOohU78q6zZNaL3GW8ai7eSeANSuQ8Vzffx7Wd8Y7i
+Pw9sYj0SMFs1UgjbuL6pO5ueHh+qyumbtAq2K0Bci0kqOcU4E9fNtdiovQARAQAB
+AA/+JHtlL39G1wsH9R6UEfUQJGXR9MiIiwZoKcnRB2o8+DS+OLjg0JOh8XehtuCs
+E/8oGQKtQqa5bEIstX7IZoYmYFiUQi9LOzIblmp2vxOm+HKkxa4JszWci2/ZmC3t
+KtaA4adl9XVnshoQ7pijuCMUKB3naBEOAxd8s9d/JeReGIYkJErdrnVfNk5N71Ds
+FmH5Ll3XtEDvgBUQP3nkA6QFjpsaB94FHjL3gDwum/cxzj6pCglcvHOzEhfY0Ddb
+J967FozQTaf2JW3O+w3LOqtcKWpq87B7+O61tVidQPSSuzPjCtFF0D2LC9R/Hpky
+KTMQ6CaKja4MPhjwywd4QPcHGYSqjMpflvJqi+kYIt8psUK/YswWjnr3r4fbuqVY
+VhtiHvnBHQjz135lUqWvEz4hM3Xpnxydx7aRlv5NlevK8+YIO5oFbWbGNTWsPZI5
+jpoFBpSsnR1Q5tnvtNHauvoWV+XN2qAOBTG+/nEbDYH6Ak3aaE9jrpTdYh0CotYF
+q7csANsDy3JvkAzeU6WnYpsHHaAjqOGyiZGsLej1UcXPFMosE/aUo4WQhiS8Zx2c
+zOVKOi/X5vQ2GdNT9Qolz8AriwzsvFR+bxPzyd8V6ALwDsoXvwEYinYBKK8j0OPv
+OOihSR6HVsuP9NUZNU9ewiGzte/+/r6pNXHvR7wTQ8EWLcEIAN6Zyrb0bHZTIlxt
+VWur/Ht2mIZrBaO50qmM5RD3T5oXzWXi/pjLrIpBMfeZR9DWfwQwjYzwqi7pxtYx
+nJvbMuY505rfnMoYxb4J+cpRXV8MS7Dr1vjjLVUC9KiwSbM3gg6emfd2yuA93ihv
+Pe3mffzLIiQa4mRE3wtGcioC43nWuV2K2e1KjxeFg07JhrezA/1Cak505ab/tmvP
+4YmjR5c44+yL/YcQ3HdFgs4mV+nVbptRXvRcPpolJsgxPccGNdvHhsoR4gwXMS3F
+RRPD2z6x8xeN73Q4KH3bm01swQdwFBZbWVfmUGLxvN7leCdfs9+iFJyqHiCIB6Iv
+mQfp8F0IAOwSo8JhWN+V1dwML4EkIrM8wUb4yecNLkyR6TpPH/qXx4PxVMC+vy6x
+sCtjeHIwKE+9vqnlhd5zOYh7qYXEJtYwdeDDmDbL8oks1LFfd+FyAuZXY33DLwn0
+cRYsr2OEZmaajqUB3NVmj3H4uJBN9+paFHyFSXrH68K1Fk2o3n+RSf2EiX+eICwI
+L6rqoF5sSVUghBWdNegV7qfy4anwTQwrIMGjgU5S6PKW0Dr/3iO5z3qQpGPAj5OW
+ATqPWkDICLbObPxD5cJlyyNE2wCA9VVc6/1d6w4EVwSq9h3/WTpATEreXXxTGptd
+LNiTA1nmakBYNO2Iyo3djhaqBdWjk+EIAKtVEnJH9FAVwWOvaj1RoZMA5DnDMo7e
+SnhrCXl8AL7Z1WInEaybasTJXn1uQ8xY52Ua4b8cbuEKRKzw/70NesFRoMLYoHTO
+dyeszvhoDHberpGRTciVmpMu7Hyi33rM31K9epA4ib6QbbCHnxkWOZB+Bhgj1hJ8
+xb4RBYWiWpAYcg0+DAC3w9gfxQhtUlZPIbmbrBmrVkO2GVGUj8kH6k4UV6kUHEGY
+HQWQR0HcbKcXW81ZXCCD0l7ROuEWQtTe5Jw7dJ4/QFuqZnPutXVRNOZqpl6eRShw
+7X2/a29VXBpmHA95a88rSQsL+qm7Fb3prqRmuMCtrUZgFz7HLSTuUMR867QcTGVh
+cCBUZXN0IEtleSA8bGVhcEBsZWFwLnNlPokCNwQTAQgAIQUCUL352QIbAwULCQgH
+AwUVCgkICwUWAgMBAAIeAQIXgAAKCRAvRV4oJNGN30+xEACh9yLkZ4jqW0/wwyIM
+MI896MQf1tAwzMj16MJYUjrjNK4Bn57QaQW926HsxF8C/OjT0MTRhq7heYZJnnEo
+rj0rzpkJapUveTRkKeoTRtGGigqJYfkOTU7KRVwgJBXIfaKlI3tC3cX0j0H1fVKX
+hLxsj5pNSPRCVf2A5mePg44HtXe6oVWSJ8+EcdTa0shf03NhAtFaY0BbFGPSm9mA
+QUe4rxugwXPLctIyV4uweFo5BXFBCb4kKTBdnQi3aJwnoWLNT6rDdTe4/nhY0Hfo
+alTCYGLkhio77gBHwpTOjEMO/hZhcDMi4CvxMPw7bRxAwq4u+0j0pDhkiLcQs4U4
+Ou/fH+pia+1nF5h19cNVXIm+RX2fL0wxVYc/14AIAK3YT6PVev9XYEkogSj0P7Kb
+HKOruYpnToXJBERNJZwGL1U+ihPNUyroRf29t7u8flnXsOpCtBEIWAO8Muy5pWjV
+3O6zAUCfWetAieCQ7WrQVmdJDa7dlX3Qx1XagUzqZdAq2jVI1hOWDA2rKytnReSF
+/A97rmLaWZ8aoNCs8i4NLcy9Lbzi9QtornYGVCEmTTym0tM9L/mn7gAJ8dqUwt7n
+s24dibfElky4ZZeItD+D7OZGeh0FDuejvv2dXFqL1/pkHpGBZhEckg0fZ95NbgMC
+4pSZkZnqRpr2GwfB5aFfB6sIIJ0HGARQvfnZARAAtCP8Z9bm6KzIA7wbXx9LBIcJ
+1wQvOPf99s4nOrnQev9xH5PZ820qS9xUjrlyE2bGYAhz5Cmq56ENs7THErIdZHtQ
+uYEBprO+VFZjP50vtmCOL2PDl/xgv6J9r1Mp3KnR/m0esR+YceDW1qX07IkB5s+Q
+us80v5LmmxnWcikWmR7dt1kOyV/+M6Y6mwvfQ4x3D/QUpO7SfMCOG5DGA7hVUHU/
+Tuh8MihmMFFOLAEEQI+1wkxr1W09HaYCcB+EZqxLSaBwMeFoYPJie9dBNBgps39o
+6pDbjsO+or4JNuyoHvh8NNI5iY0IR2NMlW4mqCcHEazys2koxFTYK6YD95Vz0RkA
+K4BErCDk3lVR0PH4StmLU3gmPayIjvi9Dl9saPRyu4Xx2WVi+q6spl3ckn4c4f3+
+iD8hxVp74+wa5ew0fIXjIpMoHCar/nndsse4i8glCddINdiOPPmhI9Wi3nT+5Z2t
+9omPP2dEh0CzR+j1zvUpT3KtmhVICqhO+QP9BTJOwrp81NTlq9mbUyzTtVk/9dy3
+zoYbhKvY08k6LJ9FsQYySqtfJZ4cwl5WsOhALWwOwlMLA9wkz0eemgFxStyOylzl
+QKoIK7zHuU6XYOXa32KSPIWaLy+WgIG/u2ObWtdE3CXVIUuSt5BQFnv7XVNHJllD
+Az9VDEkOSYOiSEFVoUsAEQEAAQAP/1AagnZQZyzHDEgw4QELAspYHCWLXE5aZInX
+wTUJhK31IgIXNn9bJ0hFiSpQR2xeMs9oYtRuPOu0P8oOFMn4/z374fkjZy8QVY3e
+PlL+3EUeqYtkMwlGNmVw5a/NbNuNfm5Darb7pEfbYd1gPcni4MAYw7R2SG/57GbC
+9gucvspHIfOSfBNLBthDzmK8xEKe1yD2eimfc2T7IRYb6hmkYfeds5GsqvGI6mwI
+85h4uUHWRc5JOlhVM6yX8hSWx0L60Z3DZLChmc8maWnFXd7C8eQ6P1azJJbW71Ih
+7CoK0XW4LE82vlQurSRFgTwfl7wFYszW2bOzCuhHDDtYnwH86Nsu0DC78ZVRnvxn
+E8Ke/AJgrdhIOo4UAyR+aZD2+2mKd7/waOUTUrUtTzc7i8N3YXGi/EIaNReBXaq+
+ZNOp24BlFzRp+FCF/pptDW9HjPdiV09x0DgICmeZS4Gq/4vFFIahWctg52NGebT0
+Idxngjj+xDtLaZlLQoOz0n5ByjO/Wi0ANmMv1sMKCHhGvdaSws2/PbMR2r4caj8m
+KXpIgdinM/wUzHJ5pZyF2U/qejsRj8Kw8KH/tfX4JCLhiaP/mgeTuWGDHeZQERAT
+xPmRFHaLP9/ZhvGNh6okIYtrKjWTLGoXvKLHcrKNisBLSq+P2WeFrlme1vjvJMo/
+jPwLT5o9CADQmcbKZ+QQ1ZM9v99iDZol7SAMZX43JC019sx6GK0u6xouJBcLfeB4
+OXacTgmSYdTa9RM9fbfVpti01tJ84LV2SyL/VJq/enJF4XQPSynT/tFTn1PAor6o
+tEAAd8fjKdJ6LnD5wb92SPHfQfXqI84rFEO8rUNIE/1ErT6DYifDzVCbfD2KZdoF
+cOSp7TpD77sY1bs74ocBX5ejKtd+aH99D78bJSMM4pSDZsIEwnomkBHTziubPwJb
+OwnATy0LmSMAWOw5rKbsh5nfwCiUTM20xp0t5JeXd+wPVWbpWqI2EnkCEN+RJr9i
+7dp/ymDQ+Yt5wrsN3NwoyiexPOG91WQVCADdErHsnglVZZq9Z8Wx7KwecGCUurJ2
+H6lKudv5YOxPnAzqZS5HbpZd/nRTMZh2rdXCr5m2YOuewyYjvM757AkmUpM09zJX
+MQ1S67/UX2y8/74TcRF97Ncx9HeELs92innBRXoFitnNguvcO6Esx4BTe1OdU6qR
+ER3zAmVf22Le9ciXbu24DN4mleOH+OmBx7X2PqJSYW9GAMTsRB081R6EWKH7romQ
+waxFrZ4DJzZ9ltyosEJn5F32StyLrFxpcrdLUoEaclZCv2qka7sZvi0EvovDVEBU
+e10jOx9AOwf8Gj2ufhquQ6qgVYCzbP+YrodtkFrXRS3IsljIchj1M2ffB/0bfoUs
+rtER9pLvYzCjBPg8IfGLw0o754Qbhh/ReplCRTusP/fQMybvCvfxreS3oyEriu/G
+GufRomjewZ8EMHDIgUsLcYo2UHZsfF7tcazgxMGmMvazp4r8vpgrvW/8fIN/6Adu
+tF+WjWDTvJLFJCe6O+BFJOWrssNrrra1zGtLC1s8s+Wfpe+bGPL5zpHeebGTwH1U
+22eqgJArlEKxrfarz7W5+uHZJHSjF/K9ZvunLGD0n9GOPMpji3UO3zeM8IYoWn7E
+/EWK1XbjnssNemeeTZ+sDh+qrD7BOi+vCX1IyBxbfqnQfJZvmcPWpruy1UsO+aIC
+0GY8Jr3OL69dDQ21jueJAh8EGAEIAAkFAlC9+dkCGwwACgkQL0VeKCTRjd9HCw/+
+LQSVgLLF4ulYlPCjWIIuQwrPbJfWUVVr2dPUFVM85DCv8gBzk5c121snXh9Swovm
+laBbw6ate3BmbXLh64jVE9Za5sbTWi7PCcbO/bpRy4d6oLmitmNw6cq0vjTLxUYy
+bwuiJxWREkfxuU85EKdouN062YDevH+/YResmlJrcCE7LRlJFeRlKsrrwBU3BqYd
+GgFJjKjQC1peeQ9fj62Y7xfwE9+PXbkiWO5u/Bk8hb1VZH1SoIRU98NHVcp6BVvp
+VK0jLAXuSauSczULmpRjbyt1lhaAqivDTWEEZXiNNbRyp17c3nVdPWOcgBr42hdQ
+z25CgZgyLCsvu82wuXLKJblrIPJX3Yf+si6KqEWBsmwdOWybsjygaF5HvzgFqAAD
+U0goPWoQ71PorP2XOUNp5ZLkBQp5etvtkksjVNMIhnHn8PGMuoxO39EUGlWj2B5l
+Cu8tSosAzB1pS8NcLZzoNoI9dOHrmgJmP+GrOUkcf5GhNZbMoj4GNfGBRYX0SZlQ
+GuDrwNKYj73C4MWyNnnUFyq8nDHJ/G1NpaF2hiof9RBL4PUU/f92JkceXPBXA8gL
+Mz2ig1OButwPPLFGQhWqxXAGrsS3Ny+BhTJfnfIbbkaLLphBpDZm1D9XKbAUvdd1
+RZXoH+FTg9UAW87eqU610npOkT6cRaBxaMK/mDtGNdc=
+=JTFu
+-----END PGP PRIVATE KEY BLOCK-----
+"""
diff --git a/src/leap/mail/smtp/tests/mail.txt b/src/leap/mail/smtp/tests/mail.txt
new file mode 100644
index 0000000..9542047
--- /dev/null
+++ b/src/leap/mail/smtp/tests/mail.txt
@@ -0,0 +1,10 @@
+HELO drebs@riseup.net
+MAIL FROM: drebs@riseup.net
+RCPT TO: drebs@riseup.net
+RCPT TO: drebs@leap.se
+DATA
+Subject: leap test
+
+Hello world!
+.
+QUIT
diff --git a/src/leap/mail/smtp/tests/test_smtprelay.py b/src/leap/mail/smtp/tests/test_smtprelay.py
new file mode 100644
index 0000000..eaa4d04
--- /dev/null
+++ b/src/leap/mail/smtp/tests/test_smtprelay.py
@@ -0,0 +1,78 @@
+from datetime import datetime
+import re
+from leap.email.smtp.smtprelay import (
+ SMTPFactory,
+ #SMTPDelivery, # an object
+ EncryptedMessage,
+)
+from leap.email.smtp import tests
+from twisted.test import proto_helpers
+from twisted.mail.smtp import User
+
+
+# some regexps
+IP_REGEX = "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}" + \
+ "([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])"
+HOSTNAME_REGEX = "(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*" + \
+ "([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])"
+IP_OR_HOST_REGEX = '(' + IP_REGEX + '|' + HOSTNAME_REGEX + ')'
+
+
+class TestSmtpRelay(tests.OpenPGPTestCase):
+
+ EMAIL_DATA = ['HELO relay.leap.se',
+ 'MAIL FROM: <user@leap.se>',
+ 'RCPT TO: <leap@leap.se>',
+ 'DATA',
+ 'From: User <user@leap.se>',
+ 'To: Leap <leap@leap.se>',
+ 'Date: ' + datetime.now().strftime('%c'),
+ 'Subject: test message',
+ '',
+ 'This is a secret message.',
+ 'Yours,',
+ 'A.',
+ '',
+ '.',
+ 'QUIT']
+
+ def assertMatch(self, string, pattern, msg=None):
+ if not re.match(pattern, string):
+ msg = self._formatMessage(msg, '"%s" does not match pattern "%s".'
+ % (string, pattern))
+ raise self.failureException(msg)
+
+ def test_relay_accepts_valid_email(self):
+ """
+ Test if SMTP server responds correctly for valid interaction.
+ """
+
+ SMTP_ANSWERS = ['220 ' + IP_OR_HOST_REGEX +
+ ' NO UCE NO UBE NO RELAY PROBES',
+ '250 ' + IP_OR_HOST_REGEX + ' Hello ' +
+ IP_OR_HOST_REGEX + ', nice to meet you',
+ '250 Sender address accepted',
+ '250 Recipient address accepted',
+ '354 Continue']
+ proto = SMTPFactory(None, self._gpg).buildProtocol(('127.0.0.1', 0))
+ transport = proto_helpers.StringTransport()
+ proto.makeConnection(transport)
+ for i, line in enumerate(self.EMAIL_DATA):
+ proto.lineReceived(line + '\r\n')
+ self.assertMatch(transport.value(),
+ '\r\n'.join(SMTP_ANSWERS[0:i + 1]))
+ proto.setTimeout(None)
+
+ def test_message_encrypt(self):
+ """
+ Test if message gets encrypted to destination email.
+ """
+ proto = SMTPFactory(None, self._gpg).buildProtocol(('127.0.0.1', 0))
+ user = User('leap@leap.se', 'relay.leap.se', proto, 'leap@leap.se')
+ m = EncryptedMessage(user, None, self._gpg)
+ for line in self.EMAIL_DATA[4:12]:
+ m.lineReceived(line)
+ m.parseMessage()
+ m.encrypt()
+ decrypted = str(self._gpg.decrypt(m.cyphertext))
+ self.assertEqual('\n'.join(self.EMAIL_DATA[9:12]), decrypted)