summaryrefslogtreecommitdiff
path: root/src/leap/mail/smtp
diff options
context:
space:
mode:
Diffstat (limited to 'src/leap/mail/smtp')
-rw-r--r--src/leap/mail/smtp/__init__.py50
-rw-r--r--src/leap/mail/smtp/bounces.py90
-rw-r--r--src/leap/mail/smtp/gateway.py218
-rw-r--r--src/leap/mail/smtp/tests/test_gateway.py91
4 files changed, 332 insertions, 117 deletions
diff --git a/src/leap/mail/smtp/__init__.py b/src/leap/mail/smtp/__init__.py
index 7b62808..9fab70a 100644
--- a/src/leap/mail/smtp/__init__.py
+++ b/src/leap/mail/smtp/__init__.py
@@ -23,47 +23,35 @@ import os
from twisted.internet import reactor
from twisted.internet.error import CannotListenError
-from leap.mail.outgoing.service import OutgoingMail
from leap.common.events import emit_async, catalog
+
from leap.mail.smtp.gateway import SMTPFactory
logger = logging.getLogger(__name__)
-def setup_smtp_gateway(port, userid, keymanager, smtp_host, smtp_port,
- smtp_cert, smtp_key, encrypted_only):
- """
- Setup SMTP gateway to run with Twisted.
+SMTP_PORT = 2013
+
- This function sets up the SMTP gateway configuration and the Twisted
- reactor.
+def run_service(soledad_sessions, keymanager_sessions, sendmail_opts,
+ port=SMTP_PORT):
+ """
+ Main entry point to run the service from the client.
- :param port: The port in which to run the server.
- :type port: int
- :param userid: The user currently logged in
- :type userid: str
- :param keymanager: A Key Manager from where to get recipients' public
- keys.
- :type keymanager: leap.common.keymanager.KeyManager
- :param smtp_host: The hostname of the remote SMTP server.
- :type smtp_host: str
- :param smtp_port: The port of the remote SMTP server.
- :type smtp_port: int
- :param smtp_cert: The client certificate for authentication.
- :type smtp_cert: str
- :param smtp_key: The client key for authentication.
- :type smtp_key: str
- :param encrypted_only: Whether the SMTP gateway should send unencrypted
- mail or not.
- :type encrypted_only: bool
+ :param soledad_sessions: a dict-like object, containing instances
+ of a Store (soledad instances), indexed by userid.
+ :param keymanager_sessions: a dict-like object, containing instances
+ of Keymanager, indexed by userid.
+ :param sendmail_opts: a dict-like object of sendmailOptions.
- :returns: tuple of SMTPFactory, twisted.internet.tcp.Port
+ :returns: the port as returned by the reactor when starts listening, and
+ the factory for the protocol.
+ :rtype: tuple
"""
- # configure the use of this service with twistd
- outgoing_mail = OutgoingMail(
- userid, keymanager, smtp_cert, smtp_key, smtp_host, smtp_port)
- factory = SMTPFactory(userid, keymanager, encrypted_only, outgoing_mail)
+ factory = SMTPFactory(soledad_sessions, keymanager_sessions,
+ sendmail_opts)
+
try:
interface = "localhost"
# don't bind just to localhost if we are running on docker since we
@@ -71,8 +59,10 @@ def setup_smtp_gateway(port, userid, keymanager, smtp_host, smtp_port,
if os.environ.get("LEAP_DOCKERIZED"):
interface = ''
+ # TODO Use Endpoints instead --------------------------------
tport = reactor.listenTCP(port, factory, interface=interface)
emit_async(catalog.SMTP_SERVICE_STARTED, str(port))
+
return factory, tport
except CannotListenError:
logger.error("STMP Service failed to start: "
diff --git a/src/leap/mail/smtp/bounces.py b/src/leap/mail/smtp/bounces.py
new file mode 100644
index 0000000..7a4674b
--- /dev/null
+++ b/src/leap/mail/smtp/bounces.py
@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+# bounces.py
+# Copyright (C) 2016 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/>.
+"""
+Deliver bounces to the user Inbox.
+"""
+import time
+from email.message import Message
+from email.utils import formatdate
+
+from leap.mail.constants import INBOX_NAME
+from leap.mail.mail import Account
+
+
+# TODO implement localization for this template.
+
+BOUNCE_TEMPLATE = """This is your local Bitmask Mail Agent running at localhost.
+
+I'm sorry to have to inform you that your message could not be delivered to one
+or more recipients.
+
+The reasons I got for the error are:
+
+{raw_error}
+
+If the problem persists and it's not a network connectivity issue, you might
+want to contact your provider ({provider}) with this information (remove any
+sensitive data before).
+
+--- Original message (*before* it was encrypted by bitmask) below ----:
+
+{orig}"""
+
+
+class Bouncer(object):
+ """
+ Implements a mechanism to deliver bounces to user inbox.
+ """
+ # TODO this should follow RFC 6522, and compose a correct multipart
+ # attaching the report and the original message. Leaving this for a future
+ # iteration.
+
+ def __init__(self, inbox_collection):
+ self._inbox_collection = inbox_collection
+
+ def bounce_message(self, error_data, to, date=None, orig=''):
+ if not date:
+ date = formatdate(time.time())
+
+ raw_data = self._format_msg(error_data, to, date, orig)
+ d = self._inbox_collection.add_msg(
+ raw_data, ('\\Recent',), date=date)
+ return d
+
+ def _format_msg(self, error_data, to, date, orig):
+ provider = to.split('@')[1]
+
+ msg = Message()
+ msg.add_header(
+ 'From', 'bitmask-bouncer@localhost (Bitmask Local Agent)')
+ msg.add_header('To', to)
+ msg.add_header('Subject', 'Undelivered Message')
+ msg.add_header('Date', date)
+ msg.set_payload(BOUNCE_TEMPLATE.format(
+ raw_error=error_data,
+ provider=provider,
+ orig=orig))
+
+ return msg.as_string()
+
+
+def bouncerFactory(soledad):
+ user_id = soledad.uuid
+ acc = Account(soledad, user_id)
+ d = acc.callWhenReady(lambda _: acc.get_collection_by_mailbox(INBOX_NAME))
+ d.addCallback(lambda inbox: Bouncer(inbox))
+ return d
diff --git a/src/leap/mail/smtp/gateway.py b/src/leap/mail/smtp/gateway.py
index 45560bf..bd0be6f 100644
--- a/src/leap/mail/smtp/gateway.py
+++ b/src/leap/mail/smtp/gateway.py
@@ -29,19 +29,27 @@ The following classes comprise the SMTP gateway service:
* EncryptedMessage - An implementation of twisted.mail.smtp.IMessage that
knows how to encrypt/sign itself before sending.
"""
+from email.Header import Header
from zope.interface import implements
+from zope.interface import implementer
+
+from twisted.cred.portal import Portal, IRealm
from twisted.mail import smtp
-from twisted.internet.protocol import ServerFactory
+from twisted.mail.imap4 import LOGINCredentials, PLAINCredentials
+from twisted.internet import defer, protocol
from twisted.python import log
-from email.Header import Header
from leap.common.check import leap_assert_type
from leap.common.events import emit_async, catalog
-from leap.keymanager.openpgp import OpenPGPKey
-from leap.keymanager.errors import KeyNotFound
+from leap.mail import errors
+from leap.mail.cred import LocalSoledadTokenChecker
from leap.mail.utils import validate_address
from leap.mail.rfc3156 import RFC3156CompliantGenerator
+from leap.mail.outgoing.service import outgoingFactory
+from leap.mail.smtp.bounces import bouncerFactory
+from leap.keymanager.openpgp import OpenPGPKey
+from leap.keymanager.errors import KeyNotFound
# replace email generator with a RFC 3156 compliant one.
from email import generator
@@ -49,84 +57,176 @@ from email import generator
generator.Generator = RFC3156CompliantGenerator
-#
-# Helper utilities
-#
-
LOCAL_FQDN = "bitmask.local"
-class SMTPHeloLocalhost(smtp.SMTP):
+@implementer(IRealm)
+class LocalSMTPRealm(object):
+
+ _encoding = 'utf-8'
+
+ def __init__(self, keymanager_sessions, soledad_sessions, sendmail_opts,
+ encrypted_only=False):
+ """
+ :param keymanager_sessions: a dict-like object, containing instances
+ of a Keymanager objects, indexed by
+ userid.
+ """
+ self._keymanager_sessions = keymanager_sessions
+ self._soledad_sessions = soledad_sessions
+ self._sendmail_opts = sendmail_opts
+ self.encrypted_only = encrypted_only
+
+ def requestAvatar(self, avatarId, mind, *interfaces):
+
+ if isinstance(avatarId, str):
+ avatarId = avatarId.decode(self._encoding)
+
+ def gotKeymanagerAndSoledad(result):
+ keymanager, soledad = result
+ d = bouncerFactory(soledad)
+ d.addCallback(lambda bouncer: (keymanager, soledad, bouncer))
+ return d
+
+ def getMessageDelivery(result):
+ keymanager, soledad, bouncer = result
+ # TODO use IMessageDeliveryFactory instead ?
+ # it could reuse the connections.
+ if smtp.IMessageDelivery in interfaces:
+ userid = avatarId
+ opts = self.getSendingOpts(userid)
+
+ outgoing = outgoingFactory(
+ userid, keymanager, opts, bouncer=bouncer)
+ avatar = SMTPDelivery(userid, keymanager, self.encrypted_only,
+ outgoing)
+
+ return (smtp.IMessageDelivery, avatar,
+ getattr(avatar, 'logout', lambda: None))
+
+ raise NotImplementedError(self, interfaces)
+
+ d1 = self.lookupKeymanagerInstance(avatarId)
+ d2 = self.lookupSoledadInstance(avatarId)
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(gotKeymanagerAndSoledad)
+ d.addCallback(getMessageDelivery)
+ return d
+
+ def lookupKeymanagerInstance(self, userid):
+ print 'getting KM INSTNACE>>>'
+ try:
+ keymanager = self._keymanager_sessions[userid]
+ except:
+ raise errors.AuthenticationError(
+ 'No keymanager session found for user %s. Is it authenticated?'
+ % userid)
+ # XXX this should return the instance after whenReady callback
+ return defer.succeed(keymanager)
+
+ def lookupSoledadInstance(self, userid):
+ try:
+ soledad = self._soledad_sessions[userid]
+ except:
+ raise errors.AuthenticationError(
+ 'No soledad session found for user %s. Is it authenticated?'
+ % userid)
+ # XXX this should return the instance after whenReady callback
+ return defer.succeed(soledad)
+
+ def getSendingOpts(self, userid):
+ try:
+ opts = self._sendmail_opts[userid]
+ except KeyError:
+ raise errors.ConfigurationError(
+ 'No sendingMail options found for user %s' % userid)
+ return opts
+
+
+class SMTPTokenChecker(LocalSoledadTokenChecker):
+ """A credentials checker that will lookup a token for the SMTP service.
+ For now it will be using the same identifier than IMAPTokenChecker"""
+
+ service = 'mail_auth'
+
+ # TODO besides checking for token credential,
+ # we could also verify the certificate here.
+
+
+class LEAPInitMixin(object):
+
+ """
+ A Mixin that takes care of initialization of all the data needed to access
+ LEAP sessions.
"""
- An SMTP class that ensures a proper FQDN
- for localhost.
+ def __init__(self, soledad_sessions, keymanager_sessions, sendmail_opts,
+ encrypted_only=False):
+ realm = LocalSMTPRealm(
+ keymanager_sessions, soledad_sessions, sendmail_opts,
+ encrypted_only)
+ portal = Portal(realm)
+
+ checker = SMTPTokenChecker(soledad_sessions)
+ self.checker = checker
+ self.portal = portal
+ portal.registerChecker(checker)
- This avoids a problem in which unproperly configured providers
- would complain about the helo not being a fqdn.
+
+class LocalSMTPServer(smtp.ESMTP, LEAPInitMixin):
+ """
+ The Production ESMTP Server: Authentication Needed.
+ Authenticates against SMTP Token stored in Local Soledad instance.
+ The Realm will produce a Delivery Object that handles encryption/signing.
"""
- def __init__(self, *args):
- smtp.SMTP.__init__(self, *args)
- self.host = LOCAL_FQDN
+ # TODO: implement Queue using twisted.mail.mail.MailService
+
+ def __init__(self, soledads, keyms, sendmailopts, *args, **kw):
+ encrypted_only = kw.pop('encrypted_only', False)
+
+ LEAPInitMixin.__init__(self, soledads, keyms, sendmailopts,
+ encrypted_only)
+ smtp.ESMTP.__init__(self, *args, **kw)
-class SMTPFactory(ServerFactory):
+# TODO implement retries -- see smtp.SenderMixin
+class SMTPFactory(protocol.ServerFactory):
"""
Factory for an SMTP server with encrypted gatewaying capabilities.
"""
+
+ protocol = LocalSMTPServer
domain = LOCAL_FQDN
+ timeout = 600
+ encrypted_only = False
- def __init__(self, userid, keymanager, encrypted_only, outgoing_mail):
- """
- Initialize the SMTP factory.
+ def __init__(self, soledad_sessions, keymanager_sessions, sendmail_opts,
+ deferred=None, retries=3):
- :param userid: The user currently logged in
- :type userid: unicode
- :param keymanager: A Key Manager from where to get recipients' public
- keys.
- :param encrypted_only: Whether the SMTP gateway should send unencrypted
- mail or not.
- :type encrypted_only: bool
- :param outgoing_mail: The outgoing mail to send the message
- :type outgoing_mail: leap.mail.outgoing.service.OutgoingMail
- """
-
- leap_assert_type(encrypted_only, bool)
- # and store them
- self._userid = userid
- self._km = keymanager
- self._outgoing_mail = outgoing_mail
- self._encrypted_only = encrypted_only
+ self._soledad_sessions = soledad_sessions
+ self._keymanager_sessions = keymanager_sessions
+ self._sendmail_opts = sendmail_opts
def buildProtocol(self, addr):
- """
- Return a protocol suitable for the job.
-
- :param addr: An address, e.g. a TCP (host, port).
- :type addr: twisted.internet.interfaces.IAddress
-
- @return: The protocol.
- @rtype: SMTPDelivery
- """
- smtpProtocol = SMTPHeloLocalhost(
- SMTPDelivery(
- self._userid, self._km, self._encrypted_only,
- self._outgoing_mail))
- smtpProtocol.factory = self
- return smtpProtocol
+ p = self.protocol(
+ self._soledad_sessions, self._keymanager_sessions,
+ self._sendmail_opts, encrypted_only=self.encrypted_only)
+ p.factory = self
+ p.host = LOCAL_FQDN
+ p.challengers = {"LOGIN": LOGINCredentials, "PLAIN": PLAINCredentials}
+ return p
#
# SMTPDelivery
#
+@implementer(smtp.IMessageDelivery)
class SMTPDelivery(object):
"""
Validate email addresses and handle message delivery.
"""
- implements(smtp.IMessageDelivery)
-
def __init__(self, userid, keymanager, encrypted_only, outgoing_mail):
"""
Initialize the SMTP delivery object.
@@ -202,20 +302,21 @@ class SMTPDelivery(object):
def found(_):
log.msg("Accepting mail for %s..." % user.dest.addrstr)
emit_async(catalog.SMTP_RECIPIENT_ACCEPTED_ENCRYPTED,
- user.dest.addrstr)
+ self._userid, user.dest.addrstr)
def not_found(failure):
failure.trap(KeyNotFound)
# if key was not found, check config to see if will send anyway
if self._encrypted_only:
- emit_async(catalog.SMTP_RECIPIENT_REJECTED, user.dest.addrstr)
+ emit_async(catalog.SMTP_RECIPIENT_REJECTED, self._userid,
+ user.dest.addrstr)
raise smtp.SMTPBadRcpt(user.dest.addrstr)
log.msg("Warning: will send an unencrypted message (because "
"encrypted_only' is set to False).")
emit_async(
catalog.SMTP_RECIPIENT_ACCEPTED_UNENCRYPTED,
- user.dest.addrstr)
+ self._userid, user.dest.addrstr)
def encrypt_func(_):
return lambda: EncryptedMessage(user, self._outgoing_mail)
@@ -307,7 +408,8 @@ class EncryptedMessage(object):
"""
log.msg("Connection lost unexpectedly!")
log.err()
- emit_async(catalog.SMTP_CONNECTION_LOST, self._user.dest.addrstr)
+ emit_async(catalog.SMTP_CONNECTION_LOST, self._userid,
+ self._user.dest.addrstr)
# unexpected loss of connection; don't save
self._lines = []
diff --git a/src/leap/mail/smtp/tests/test_gateway.py b/src/leap/mail/smtp/tests/test_gateway.py
index 0b9a364..df83cf0 100644
--- a/src/leap/mail/smtp/tests/test_gateway.py
+++ b/src/leap/mail/smtp/tests/test_gateway.py
@@ -15,7 +15,6 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-
"""
SMTP gateway tests.
"""
@@ -23,19 +22,18 @@ SMTP gateway tests.
import re
from datetime import datetime
+from twisted.mail import smtp
from twisted.internet import reactor
from twisted.internet.defer import inlineCallbacks, fail, succeed, Deferred
from twisted.test import proto_helpers
from mock import Mock
-from leap.mail.smtp.gateway import (
- SMTPFactory
-)
-from leap.mail.tests import (
- TestCaseWithKeyManager,
- ADDRESS,
- ADDRESS_2,
-)
+from leap.mail.smtp.gateway import SMTPFactory, LOCAL_FQDN
+from leap.mail.smtp.gateway import SMTPDelivery
+
+from leap.mail.outgoing.service import outgoingFactory
+from leap.mail.tests import TestCaseWithKeyManager
+from leap.mail.tests import ADDRESS, ADDRESS_2
from leap.keymanager import openpgp, errors
@@ -46,6 +44,52 @@ 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 + ')'
+TEST_USER = u'anotheruser@leap.se'
+
+
+def getSMTPFactory(soledad_s, keymanager_s, sendmail_opts,
+ encrypted_only=False):
+ factory = UnauthenticatedSMTPFactory
+ factory.encrypted_only = encrypted_only
+ proto = factory(
+ soledad_s, keymanager_s, sendmail_opts).buildProtocol(('127.0.0.1', 0))
+ return proto
+
+
+class UnauthenticatedSMTPServer(smtp.SMTP):
+
+ encrypted_only = False
+
+ def __init__(self, soledads, keyms, opts, encrypted_only=False):
+ smtp.SMTP.__init__(self)
+
+ userid = TEST_USER
+ keym = keyms[userid]
+
+ class Opts:
+ cert = '/tmp/cert'
+ key = '/tmp/cert'
+ hostname = 'remote'
+ port = 666
+
+ outgoing = outgoingFactory(
+ userid, keym, Opts, check_cert=False)
+ avatar = SMTPDelivery(userid, keym, encrypted_only, outgoing)
+ self.delivery = avatar
+
+ def validateFrom(self, helo, origin):
+ return origin
+
+
+class UnauthenticatedSMTPFactory(SMTPFactory):
+ """
+ A Factory that produces a SMTP server that does not authenticate user.
+ Only for tests!
+ """
+ protocol = UnauthenticatedSMTPServer
+ domain = LOCAL_FQDN
+ encrypted_only = False
+
class TestSmtpGateway(TestCaseWithKeyManager):
@@ -85,14 +129,8 @@ class TestSmtpGateway(TestCaseWithKeyManager):
'250 Recipient address accepted',
'354 Continue']
- # XXX this bit can be refactored away in a helper
- # method...
- proto = SMTPFactory(
- u'anotheruser@leap.se',
- self._km,
- self._config['encrypted_only'],
- outgoing_mail=Mock()).buildProtocol(('127.0.0.1', 0))
- # snip...
+ user = TEST_USER
+ proto = getSMTPFactory({user: None}, {user: self._km}, {user: None})
transport = proto_helpers.StringTransport()
proto.makeConnection(transport)
reply = ""
@@ -116,12 +154,10 @@ class TestSmtpGateway(TestCaseWithKeyManager):
# mock the key fetching
self._km._fetch_keys_from_server = Mock(
return_value=fail(errors.KeyNotFound()))
- # prepare the SMTP factory
- proto = SMTPFactory(
- u'anotheruser@leap.se',
- self._km,
- self._config['encrypted_only'],
- outgoing_mail=Mock()).buildProtocol(('127.0.0.1', 0))
+ user = TEST_USER
+ proto = getSMTPFactory(
+ {user: None}, {user: self._km}, {user: None},
+ encrypted_only=True)
transport = proto_helpers.StringTransport()
proto.makeConnection(transport)
yield self.getReply(self.EMAIL_DATA[0] + '\r\n', proto, transport)
@@ -132,7 +168,7 @@ class TestSmtpGateway(TestCaseWithKeyManager):
self.assertEqual(
'550 Cannot receive for specified address\r\n',
reply,
- 'Address should have been rejecetd with appropriate message.')
+ 'Address should have been rejected with appropriate message.')
proto.setTimeout(None)
@inlineCallbacks
@@ -149,11 +185,8 @@ class TestSmtpGateway(TestCaseWithKeyManager):
# mock the key fetching
self._km._fetch_keys_from_server = Mock(
return_value=fail(errors.KeyNotFound()))
- # prepare the SMTP factory with encrypted only equal to false
- proto = SMTPFactory(
- u'anotheruser@leap.se',
- self._km,
- False, outgoing_mail=Mock()).buildProtocol(('127.0.0.1', 0))
+ user = TEST_USER
+ proto = getSMTPFactory({user: None}, {user: self._km}, {user: None})
transport = proto_helpers.StringTransport()
proto.makeConnection(transport)
yield self.getReply(self.EMAIL_DATA[0] + '\r\n', proto, transport)