[tor-commits] [bridgedb/master] Move email autoresponder code to separate module.

isis at torproject.org isis at torproject.org
Fri Jun 6 23:39:14 UTC 2014


commit 0a0af4b89f37bbaae40bb34654fdf854858e837a
Author: Isis Lovecruft <isis at torproject.org>
Date:   Mon Jun 2 20:44:19 2014 +0000

    Move email autoresponder code to separate module.
    
     * ADD new bridgedb.email.autoresponder module.
     * MOVE b.e.server.createResponseBody() → b.e.autoresponder.createResponseBody()
     * MOVE b.e.server.generateResponse() → b.e.autoresponder.generateResponse()
     * MOVE b.e.server.MailResponse → b.e.autoresponder.EmailResponse
     * ADD new b.e.autoresponder.SMTPAutoresponder class, created mostly
       from pieces of b.e.server.MailMessage, but made more compatible with
       Twisted's existing SMTP server/client architecture.
---
 lib/bridgedb/email/autoresponder.py |  639 +++++++++++++++++++++++++++++++++++
 lib/bridgedb/email/server.py        |  409 ----------------------
 2 files changed, 639 insertions(+), 409 deletions(-)

diff --git a/lib/bridgedb/email/autoresponder.py b/lib/bridgedb/email/autoresponder.py
new file mode 100644
index 0000000..d5dadae
--- /dev/null
+++ b/lib/bridgedb/email/autoresponder.py
@@ -0,0 +1,639 @@
+# -*- coding: utf-8; test-case-name: bridgedb.test.test_email_autoresponder -*-
+#_____________________________________________________________________________
+#
+# This file is part of BridgeDB, a Tor bridge distribution system.
+#
+# :authors: Nick Mathewson <nickm at torproject.org>
+#           Isis Lovecruft <isis at torproject.org> 0xA3ADB67A2CDB8B35
+#           Matthew Finkel <sysrqb at torproject.org>
+#           please also see AUTHORS file
+# :copyright: (c) 2007-2014, The Tor Project, Inc.
+#             (c) 2013-2014, Isis Lovecruft
+# :license: see LICENSE for licensing information
+#_____________________________________________________________________________
+
+"""Functionality for autoresponding to incoming emails."""
+
+from __future__ import unicode_literals
+from __future__ import print_function
+
+import io
+import logging
+import time
+
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.mail import smtp
+from twisted.python import failure
+
+from bridgedb import safelog
+from bridgedb.crypto import gpgSignMessage
+from bridgedb.crypto import NEW_BUFFER_INTERFACE
+from bridgedb.Dist import EmailRequestedHelp
+from bridgedb.Dist import EmailRequestedKey
+from bridgedb.Dist import TooSoonEmail
+from bridgedb.Dist import IgnoreEmail
+from bridgedb.email import dkim
+from bridgedb.email import request
+from bridgedb.email import templates
+from bridgedb.parse import addr
+from bridgedb import translations
+
+
+def createResponseBody(lines, context, client, lang='en'):
+    """Parse the **lines** from an incoming email request and determine how to
+    respond.
+
+    :param list lines: The list of lines from the original request sent by the
+        client.
+    :type context: class:`MailServerContext`
+    :param context: The context which contains settings for the email server.
+    :type client: :api:`twisted.mail.smtp.Address`
+    :param client: The client's email address which should be in the
+        :header:`To:` header of the response email.
+    :param str lang: The 2-5 character locale code to use for translating the
+        email. This is obtained from a client sending a email to a valid plus
+        address which includes the translation desired, i.e. by sending an
+        email to ``bridges+fa at torproject.org``, the client should receive a
+        response in Farsi.
+    :rtype: None or str
+    :returns: None if we shouldn't respond to the client (i.e., if they have
+        already received a rate-limiting warning email). Otherwise, returns a
+        string containing the (optionally translated) body for the email
+        response which we should send out.
+    """
+    translator = translations.installTranslations(lang)
+    bridges = None
+    try:
+        bridgeRequest = request.determineBridgeRequestOptions(lines)
+
+        # The request was invalid, respond with a help email which explains
+        # valid email commands:
+        if not bridgeRequest.isValid():
+            raise EmailRequestedHelp("Email request from '%s' was invalid."
+                                     % str(client))
+
+        # Otherwise they must have requested bridges:
+        interval = context.schedule.getInterval(time.time())
+        bridges = context.distributor.getBridgesForEmail(
+            str(client),
+            interval,
+            context.nBridges,
+            countryCode=None,
+            bridgeFilterRules=bridgeRequest.filters)
+    except EmailRequestedHelp as error:
+        logging.info(error)
+        return templates.buildWelcomeText(translator, client)
+    except EmailRequestedKey as error:
+        logging.info(error)
+        return templates.buildKeyMessage(translator, client)
+    except TooSoonEmail as error:
+        logging.info("Got a mail too frequently: %s." % error)
+        return templates.buildSpamWarning(translator, client)
+    except (IgnoreEmail, addr.BadEmail) as error:
+        logging.info(error)
+        # Don't generate a response if their email address is unparsable or
+        # invalid, or if we've already warned them about rate-limiting:
+        return None
+    else:
+        answer = "(no bridges currently available)\r\n"
+        if bridges:
+            transport = bridgeRequest.justOnePTType()
+            answer = "".join("  %s\r\n" % b.getConfigLine(
+                includeFingerprint=context.includeFingerprints,
+                addressClass=bridgeRequest.addressClass,
+                transport=transport,
+                request=str(client)) for b in bridges)
+        return templates.buildAnswerMessage(translator, client, answer)
+
+def generateResponse(fromAddress, client, body, subject=None,
+                     messageID=None, gpgContext=None):
+    """Create a :class:`EmailResponse`, which acts like an in-memory
+    ``io.StringIO`` file, by creating and writing all headers and the email
+    body into the file-like ``EmailResponse.mailfile``.
+
+    :param str fromAddress: The rfc:`2821` email address which should be in
+        the :header:`From:` header.
+    :type client: :api:`twisted.mail.smtp.Address`
+    :param client: The client's email address which should be in the
+        :header:`To:` header of the response email.
+    :param str subject: The string to write to the :header:`subject` header.
+    :param str body: The body of the email. If a **gpgContext** is also given,
+        and that ``Context`` has email signing configured, then
+        :meth:`EmailResponse.writeBody` will generate and include any
+        ascii-armored OpenPGP signatures in the **body**.
+    :type messageID: None or str
+    :param messageID: The :rfc:`2822` specifier for the :header:`Message-ID:`
+        header, if including one is desirable.
+    :type gpgContext: None or ``gpgme.Context``.
+    :param gpgContext: A pre-configured GPGME context. See
+        :func:`~crypto.getGPGContext`.
+    :rtype: :class:`EmailResponse`
+    :returns: A ``EmailResponse`` which contains the entire email. To obtain
+        the contents of the email, including all headers, simply use
+        :meth:`EmailResponse.readContents`.
+    """
+    response = EmailResponse(gpgContext)
+    response.to = client
+    response.writeHeaders(fromAddress, str(client), subject,
+                          inReplyTo=messageID)
+    response.writeBody(body)
+
+    # Only log the email text (including all headers) if SAFE_LOGGING is
+    # disabled:
+    if not safelog.safe_logging:
+        contents = response.readContents()
+        logging.debug("Email contents:\n%s" % str(contents))
+    else:
+        logging.debug("Email text for %r created." % str(client))
+
+    response.rewind()
+    return response
+
+
+class EmailResponse(object):
+    """Holds information for generating a response email for a request.
+
+    .. todo:: At some point, we may want to change this class to optionally
+        handle creating Multipart MIME encoding messages, so that we can
+        include attachments. (This would be useful for attaching our GnuPG
+        keyfile, for example, rather than simply pasting it into the body of
+        the email.)
+
+    :type _buff: unicode or buffer
+    :cvar _buff: Used internally to write lines for the response email into
+        the ``_mailfile``. The reason why both of these attributes have two
+        possible types is for the same Python-buggy reasons which require
+        :data:`~bridgedb.crypto.NEW_BUFFER_INTERFACE`.
+    :type mailfile: :class:`io.StringIO` or :class:`io.BytesIO`.
+    :cvar mailfile: An in-memory file for storing the formatted headers and
+        body of the response email.
+
+    :ivar str delimiter: Delimiter between lines written to the
+        :cvar:`mailfile`.
+    :ivar bool closed: ``True`` if :meth:`close` has been called.
+    :type to: :api:`twisted.mail.smtp.Address`
+    :ivar to: The client's email address which this response should be sent to.
+    """
+    _buff = buffer if NEW_BUFFER_INTERFACE else unicode
+    mailfile = io.BytesIO if NEW_BUFFER_INTERFACE else io.StringIO
+
+    def __init__(self, gpgContext=None):
+        """Create a response to an email we have recieved.
+
+        This class deals with correctly formatting text for the response email
+        headers and the response body into an instance of :cvar:`mailfile`.
+
+        :type gpgContext: None or ``gpgme.Context``
+        :param gpgContext: A pre-configured GPGME context. See
+            :meth:`bridgedb.crypto.getGPGContext` for obtaining a
+            pre-configured **gpgContext**. If given, and the ``Context`` has
+            been configured to sign emails, then a response email body string
+            given to :meth:`writeBody` will be signed before being written
+            into the ``mailfile``.
+        """
+        self.gpgContext = gpgContext
+        self.mailfile = self.mailfile()
+        self.delimiter = '\n'
+        self.closed = False
+        self.to = None
+
+    def close(self):
+        """Close our :ivar:`mailfile` and set :ivar:`closed` to ``True``."""
+        logging.debug("Closing %s.mailfile..." % (self.__class__.__name__))
+        self.mailfile.close()
+        self.closed = True
+
+    def read(self, size=None):
+        """Read, at most, **size** bytes from our :ivar:`mailfile`.
+
+        .. note:: This method is required by Twisted's SMTP system.
+
+        :param int size: The number of bytes to read. Defaults to ``None``,
+            which reads until EOF.
+        :rtype: str
+        :returns: The bytes read from the :ivar:`mailfile`.
+        """
+        contents = ''
+        logging.debug("Reading%s from %s.mailfile..."
+                      % ((' {0} bytes'.format(size) if size else ''),
+                         self.__class__.__name__))
+        try:
+            if size is not None:
+                contents = self.mailfile.read(int(size))
+            else:
+                contents = self.mailfile.read()
+        except Exception as error:  # pragma: no cover
+            logging.exception(error)
+
+        return contents
+
+    def readContents(self):
+        """Read the all the contents written thus far to the :cvar:`mailfile`,
+        and then :meth:`seek` to return to the original pointer position we
+        were at before this method was called.
+
+        :rtype: str
+        :returns: The entire contents of the :cvar:`mailfile`.
+        """
+        pointer = self.mailfile.tell()
+        self.mailfile.seek(0)
+        contents = self.mailfile.read()
+        self.mailfile.seek(pointer)
+        return contents
+
+    def rewind(self):
+        """Rewind to the very beginning of the :cvar:`mailfile`."""
+        logging.debug("Rewinding %s.mailfile..." % self.__class__.__name__)
+        self.mailfile.seek(0)
+
+    def write(self, line):
+        """Write the **line** to the :ivar:`mailfile`.
+
+        Any **line** written to me will have :ivar:`delimiter` appended to it
+        beforehand.
+
+        :param str line: Something to append into the :ivar:`mailfile`.
+        """
+        if line.find('\r\n') != -1:
+            # If **line** contains newlines, send it to :meth:`writelines` to
+            # break it up so that we can replace them:
+            logging.debug("Found newlines in %r. Calling writelines()." % line)
+            self.writelines(line)
+        else:
+            line += self.delimiter
+            self.mailfile.write(self._buff(line.encode('utf8')))
+            self.mailfile.flush()
+
+    def writelines(self, lines):
+        """Calls :meth:`write` for each line in **lines**.
+
+        Line endings of ``'\r\n'`` will be replaced with :ivar:`delimiter`
+        (i.e. ``'\n'``). See :api:`twisted.mail.smtp.SMTPClient.getMailData`
+        for the reason.
+
+        :type lines: basestring or list
+        :param lines: The lines to write to the :ivar:`mailfile`.
+        """
+        if isinstance(lines, basestring):
+            lines = lines.replace('\r\n', '\n')
+            for ln in lines.split('\n'):
+                self.write(ln)
+        elif isinstance(lines, (list, tuple,)):
+            for ln in lines:
+                self.write(ln)
+
+    def writeHeaders(self, fromAddress, toAddress, subject=None,
+                     inReplyTo=None, includeMessageID=True,
+                     contentType='text/plain; charset="utf-8"', **kwargs):
+        """Write all headers into the response email.
+
+        :param str fromAddress: The email address for the ``From:`` header.
+        :param str toAddress: The email address for the ``To:`` header.
+        :type subject: None or str
+        :param subject: The ``Subject:`` header.
+        :type inReplyTo: None or str
+        :param inReplyTo: If set, an ``In-Reply-To:`` header will be
+            generated. This should be set to the ``Message-ID:`` header from
+            the client's original request email.
+        :param bool includeMessageID: If ``True``, generate and include a
+            ``Message-ID:`` header for the response.
+        :param str contentType: The ``Content-Type:`` header.
+        :kwargs: If given, the key will become the name of the header, and the
+            value will become the Contents of that header.
+        """
+        self.write("From: %s" % fromAddress)
+        self.write("To: %s" % toAddress)
+        if includeMessageID:
+            self.write("Message-ID: %s" % smtp.messageid())
+        if inReplyTo:
+            self.write("In-Reply-To: %s" % inReplyTo)
+        self.write("Content-Type: %s" % contentType)
+        self.write("Date: %s" % smtp.rfc822date())
+
+        if not subject:
+            subject = '[no subject]'
+        if not subject.lower().startswith('re'):
+            subject = "Re: " + subject
+        self.write("Subject: %s" % subject)
+
+        if kwargs:
+            for headerName, headerValue in kwargs.items():
+                headerName = headerName.capitalize()
+                headerName = headerName.replace(' ', '-')
+                headerName = headerName.replace('_', '-')
+                self.write("%s: %s" % (headerName, headerValue))
+
+        # The first blank line designates that the headers have ended:
+        self.write(self.delimiter)
+
+    def writeBody(self, body):
+        """Write the response body into the :cvar:`mailfile`.
+
+        If ``EmailResponse.gpgContext`` is set, and signing is configured, the
+        **body** will be automatically signed before writing its contents into
+        the ``mailfile``.
+
+        :param str body: The body of the response email.
+        """
+        logging.info("Writing email body...")
+        if self.gpgContext:
+            logging.info("Attempting to sign email...")
+            body, _ = gpgSignMessage(self.gpgContext, body)
+        self.writelines(body)
+
+
+class SMTPAutoresponder(smtp.SMTPClient):
+    """An :api:`twisted.mail.smtp.SMTPClient` for responding to incoming mail.
+
+    The main worker in this class is the :meth:`reply` method, which functions
+    to dissect an incoming email from an incoming :class:`SMTPMessage` and
+    create a :class:`EmailResponse` email message in reply to it, and then,
+    finally, send it out.
+
+    :ivar log: A :api:`twisted.python.util.LineLog` cache of messages.
+    :ivar debug: If ``True``, enable logging (accessible via :ivar:`log`).
+    :ivar str identity: Our FQDN which will be sent during client ``HELO``.
+    :ivar incoming: An incoming
+        :api:`Message <twisted.mail.smtp.rfc822.Message>`, i.e. as returned
+        from :meth:`SMTPMessage.getIncomingMessage`.
+    :ivar deferred: A :api:`Deferred <twisted.internet.defer.Deferred>` with
+       registered callbacks, :meth:`sentMail` and :meth:`sendError`, which
+       will be given to the reactor in order to process the sending of the
+       outgoing response email.
+    """
+    debug = True
+    identity = smtp.DNSNAME
+
+    def __init__(self):
+        """Handle responding (or not) to an incoming email."""
+        smtp.SMTPClient.__init__(self, self.identity)
+        self.incoming = None
+        self.deferred = defer.Deferred()
+        self.deferred.addCallback(self.sentMail)
+        self.deferred.addErrback(self.sendError)
+
+    def getMailData(self):
+        """Gather all the data for building the response to the client.
+
+        This method must return a file-like object containing the data of the
+        message to be sent. Lines in the file should be delimited by '\n'.
+
+        :rtype: ``None`` or :class:`EmailResponse`
+        :returns: An ``EmailResponse``, if we have a response to send in reply
+            to the incoming email, otherwise, returns ``None``.
+        """
+        clients = self.getMailTo()
+        if not clients: return
+        client = clients[0]  # There should have been only one anyway
+        if not self.runChecks(client): return
+
+        recipient = self.getMailFrom()
+        # Look up the locale part in the 'To:' address, if there is one, and
+        # get the appropriate Translation object:
+        lang = translations.getLocaleFromPlusAddr(recipient)
+        logging.info("Client requested email translation: %s" % lang)
+
+        body = createResponseBody(self.incoming.lines,
+                                  self.incoming.context,
+                                  client, lang)
+        if not body: return  # The client was already warned.
+
+        messageID = self.incoming.message.getheader("Message-ID", None)
+        subject = self.incoming.message.getheader("Subject", None)
+        response = generateResponse(recipient, client,
+                                    body, subject, messageID,
+                                    self.incoming.context.gpgContext)
+        return response
+
+    def getMailTo(self):
+        """Attempt to get the client's email address from an incoming email.
+
+        :rtype: list
+        :returns: A list containing the client's
+            :func:`normalized <addr.normalizeEmail>` email
+            :api:`Address <twisted.mail.smtp.Address>`, if it originated from
+            a domain that we accept and the address was well-formed. Otherwise,
+            returns ``None``. Even though we're likely to respond to only one
+            client at a time, the return value of this method must be a list
+            in order to hook into the rest of
+            :api:`twisted.mail.smtp.SMTPClient` correctly.
+        """
+        clients = []
+        addrHeader = None
+        try: fromAddr = self.incoming.message.getaddr("From")[1]
+        except (IndexError, TypeError, AttributeError): pass
+        else: addrHeader = fromAddr
+
+        if not addrHeader:
+            logging.warn("No From header on incoming mail.")
+            try: senderHeader = self.incoming.message.getaddr("Sender")[1]
+            except (IndexError, TypeError, AttributeError): pass
+            else: addrHeader = senderHeader
+        if not addrHeader:
+            logging.warn("No Sender header on incoming mail.")
+        else:
+            try:
+                client = smtp.Address(addr.normalizeEmail(
+                    addrHeader,
+                    self.incoming.context.domainMap,
+                    self.incoming.context.domainRules))
+            except (addr.UnsupportedDomain, addr.BadEmail,
+                    smtp.AddressError) as error:
+                logging.warn(error)
+            else:
+                clients.append(client)
+        return clients
+
+    def getMailFrom(self):
+        """Find our address in the recipients list of the **incoming** message.
+
+        :rtype: str
+        :return: Our address from the recipients list. If we can't find it
+            return our default ``EMAIL_FROM_ADDRESS`` from the config file.
+        """
+        logging.debug("Searching for our email address in 'To:' header...")
+
+        ours = None
+
+        try:
+            ourAddress = smtp.Address(self.incoming.context.fromAddr)
+            allRecipients = self.incoming.message.getaddrlist("To")
+
+            for _, addr in allRecipients:
+                recipient = smtp.Address(addr)
+                if not ourAddress.domain in recipient.domain:
+                    logging.debug(("Not our domain (%s) or subdomain, skipping"
+                                   " email address: %s")
+                                  % (ourAddress.domain, str(recipient)))
+                    continue
+                # The recipient's username should at least start with ours,
+                # but it still might be a '+' address.
+                if not recipient.local.startswith(ourAddress.local):
+                    logging.debug(("Username doesn't begin with ours, skipping"
+                                   " email address: %s") % str(recipient))
+                    continue
+                # Only check the username before the first '+':
+                beforePlus = recipient.local.split('+', 1)[0]
+                if beforePlus == ourAddress.local:
+                    ours = str(recipient)
+            if not ours:
+                raise addr.BadEmail(allRecipients)
+
+        except Exception as error:
+            logging.error(("Couldn't find our email address in incoming email "
+                           "headers: %r" % error))
+            # Just return the email address that we're configured to use:
+            ours = self.incoming.context.fromAddr
+
+        logging.debug("Found our email address: %s." % ours)
+        return ours
+
+    def sentMail(self, success):
+        """Callback for a :api:`twisted.mail.smtp.SMTPSenderFactory`,
+        called when an attempt to send an email is completed.
+
+        If some addresses were accepted, code and resp are the response
+        to the DATA command. If no addresses were accepted, code is -1
+        and resp is an informative message.
+
+        :param int code: The code returned by the SMTP Server.
+        :param str resp: The string response returned from the SMTP Server.
+        :param int numOK: The number of addresses accepted by the remote host.
+        :param list addresses: A list of tuples (address, code, resp) listing
+            the response to each ``RCPT TO`` command.
+        :param log: The SMTP session log. (We don't use this, but it is sent
+            by :api:`twisted.mail.smtp.SMTPSenderFactory` nonetheless.)
+        """
+        numOk, addresses = success
+
+        for (address, code, resp) in addresses:
+            logging.info("Sent reply to %s" % address)
+            logging.debug("SMTP server response: %d %s" % (code, resp))
+
+        if self.debug:
+            for line in self.log.log:
+                if line:
+                    logging.debug(line)
+
+    def sendError(self, fail):
+        """Errback for a :api:`twisted.mail.smtp.SMTPSenderFactory`.
+
+        :param fail: A :api:`twisted.python.failure.Failure` or a
+            :api:`twisted.mail.smtp.SMTPClientError` which occurred during the
+            transaction to send the outgoing email.
+        """
+        logging.debug("called with %r" % fail)
+
+        if isinstance(fail, failure.Failure):
+            error = fail.getTraceback() or "Unknown"
+        elif isinstance(fail, Exception):
+            error = fail
+        logging.error(error)
+
+        # This handles QUIT commands, disconnecting, and closing the transport:
+        smtp.SMTPClient.sendError(self, fail)
+
+    def reply(self):
+        """Reply to an incoming email. Maybe.
+
+        If nothing is returned from either :func:`createResponseBody` or
+        :func:`generateResponse`, then the incoming email will not be
+        responded to at all. This can happen for several reasons, for example:
+        if the DKIM signature was invalid or missing, or if the incoming email
+        came from an unacceptable domain, or if there have been too many
+        emails from this client in the allotted time period.
+
+        :rtype: :api:`twisted.internet.defer.Deferred`
+        :returns: A ``Deferred`` which will callback when the response has
+            been successfully sent, or errback if an error occurred while
+            sending the email.
+        """
+        logging.info("Got an email; deciding whether to reply.")
+
+        response = self.getMailData()
+        if not response:
+            return self.deferred
+
+        return self.send(response)
+
+    def runChecks(self, client):
+        """Run checks on the incoming message, and only reply if they pass.
+
+          1. Check that the domain names, taken from the SMTP ``MAIL FROM:``
+        command and the email ``'From:'`` header, can be
+        :func:`canonicalized <addr.canonicalizeEmailDomain>`.
+
+          2. Check that those canonical domains match,
+
+          3. If the incoming message is from a domain which supports DKIM
+        signing, then run :func:`bridgedb.email.dkim.checkDKIM` as well.
+
+        .. note:: Calling this method sets the ``canonicalFromEmail`` and
+            :ivar:``canonicalDomainRules`` attributes of the :ivar:`incoming`
+            message.
+
+        :param client: An :api:`twisted.mail.smtp.Address`, which contains
+            the client's email address, extracted from the ``'From:'`` header
+            from the incoming email.
+        :rtype: bool
+        :returns: ``False`` if the checks didn't pass, ``True`` otherwise.
+        """
+        # If the SMTP ``RCPT TO:`` domain name couldn't be canonicalized, then
+        # we *should* have bailed at the SMTP layer, but we'll reject this
+        # email again nonetheless:
+        if not self.incoming.canonicalFromSMTP:
+            logging.warn(("SMTP 'MAIL FROM' wasn't from a canonical domain "
+                          "for email from %s") % str(client))
+            return False
+
+        logging.debug("Canonicalizing client email domain...")
+        # The client's address was already checked to see if it came from a
+        # supported domain and is a valid email address in :meth:`getMailTo`,
+        # so we should just be able to re-extract the canonical domain safely
+        # here:
+        canonicalFromEmail = addr.canonicalizeEmailDomain(
+            client.domain, self.incoming.canon)
+        logging.debug("Canonical email domain: %s" % canonicalFromEmail)
+
+        # The canonical domains from the SMTP ``MAIL FROM:`` and the email
+        # ``From:`` header should match:
+        if self.incoming.canonicalFromSMTP != canonicalFromEmail:
+            logging.error("SMTP/Email canonical domain mismatch!")
+            return False
+
+        domainRules = self.incoming.context.domainRules.get(
+            canonicalFromEmail, list())
+
+        # If the domain's ``domainRules`` say to check DKIM verification
+        # results, and those results look bad, reject this email:
+        if not dkim.checkDKIM(self.incoming.message, domainRules):
+            return False
+
+        self.incoming.canonicalDomainRules = domainRules
+        self.incoming.canonicalFromEmail = canonicalFromEmail
+        return True
+
+    def send(self, response, retries=0, timeout=30, reaktor=reactor):
+        """Send our **response** in reply to :ivar:`incoming`.
+
+        :type client: :api:`twisted.mail.smtp.Address`
+        :param client: The email address of the client.
+        :param response: A :class:`EmailResponse`.
+        :param int retries: Try resending this many times. (default: ``0``)
+        :param int timeout: Timeout after this many seconds. (default: ``30``)
+        :rtype: :api:`Deferred <twisted.internet.defer.Deferred>`
+        :returns: Our :ivar:`deferred`.
+        """
+        logging.info("Sending reply to %s ..." % str(response.to))
+
+        factory = smtp.SMTPSenderFactory(self.incoming.context.smtpFromAddr,
+                                         str(response.to),
+                                         response,
+                                         self.deferred,
+                                         retries=retries,
+                                         timeout=timeout)
+        reaktor.connectTCP(self.incoming.context.smtpServerIP,
+                           self.incoming.context.smtpServerPort,
+                           factory)
+        return self.deferred
diff --git a/lib/bridgedb/email/server.py b/lib/bridgedb/email/server.py
index f526b47..c526852 100644
--- a/lib/bridgedb/email/server.py
+++ b/lib/bridgedb/email/server.py
@@ -46,114 +46,6 @@ from bridgedb.parse.addr import UnsupportedDomain
 from bridgedb.parse.addr import canonicalizeEmailDomain
 
 
-def createResponseBody(lines, context, client, lang='en'):
-    """Parse the **lines** from an incoming email request and determine how to
-    respond.
-
-    :param list lines: The list of lines from the original request sent by the
-        client.
-    :type context: class:`MailContext`
-    :param context: The context which contains settings for the email server.
-    :type client: :api:`twisted.mail.smtp.Address`
-    :param client: The client's email address which should be in the
-        :header:`To:` header of the response email.
-    :param str lang: The 2-5 character locale code to use for translating the
-        email. This is obtained from a client sending a email to a valid plus
-        address which includes the translation desired, i.e. by sending an
-        email to ``bridges+fa at torproject.org``, the client should receive a
-        response in Farsi.
-    :rtype: None or str
-    :returns: None if we shouldn't respond to the client (i.e., if they have
-        already received a rate-limiting warning email). Otherwise, returns a
-        string containing the (optionally translated) body for the email
-        response which we should send out.
-    """
-    t = translations.installTranslations(lang)
-    bridges = None
-    try:
-        bridgeRequest = request.determineBridgeRequestOptions(lines)
-
-        # The request was invalid, respond with a help email which explains
-        # valid email commands:
-        if not bridgeRequest.isValid():
-            raise EmailRequestedHelp("Email request from '%s' was invalid."
-                                     % str(client))
-
-        # Otherwise they must have requested bridges:
-        interval = context.schedule.getInterval(time.time())
-        bridges = context.distributor.getBridgesForEmail(
-            str(client),
-            interval,
-            context.nBridges,
-            countryCode=None,
-            bridgeFilterRules=bridgeRequest.filters)
-    except EmailRequestedHelp as error:
-        logging.info(error)
-        return templates.buildWelcomeText(t, client)
-    except EmailRequestedKey as error:
-        logging.info(error)
-        return templates.buildKeyMessage(t, client)
-    except TooSoonEmail as error:
-        logging.info("Got a mail too frequently: %s." % error)
-        return templates.buildSpamWarning(t, client)
-    except (IgnoreEmail, BadEmail) as error:
-        logging.info(error)
-        # Don't generate a response if their email address is unparsable or
-        # invalid, or if we've already warned them about rate-limiting:
-        return None
-    else:
-        answer = "(no bridges currently available)\r\n"
-        if bridges:
-            transport = bridgeRequest.justOnePTType()
-            answer = "".join("  %s\r\n" % b.getConfigLine(
-                includeFingerprint=context.includeFingerprints,
-                addressClass=bridgeRequest.addressClass,
-                transport=transport,
-                request=str(client)) for b in bridges)
-        return templates.buildAnswerMessage(t, client, answer)
-
-def generateResponse(fromAddress, clientAddress, body, subject=None,
-                     messageID=None, gpgContext=None):
-    """Create a :class:`MailResponse`, which acts like an in-memory
-    ``io.StringIO`` file, by creating and writing all headers and the email
-    body into the file-like ``MailResponse.mailfile``.
-
-    :param str fromAddress: The rfc:`2821` email address which should be in
-        the :header:`From:` header.
-    :param str clientAddress: The rfc:`2821` email address which should be in
-        the :header:`To:` header.
-    :param str subject: The string to write to the :header:`subject` header.
-    :param str body: The body of the email. If a **gpgContext** is also given,
-        and that ``Context`` has email signing configured, then
-        :meth:`MailResponse.writeBody` will generate and include any
-        ascii-armored OpenPGP signatures in the **body**.
-    :type messageID: None or str
-    :param messageID: The :rfc:`2822` specifier for the :header:`Message-ID:`
-        header, if including one is desirable.
-    :type gpgContext: None or ``gpgme.Context``.
-    :param gpgContext: A pre-configured GPGME context. See
-        :meth:`~crypto.getGPGContext`.
-    :rtype: :class:`MailResponse`
-    :returns: A ``MailResponse`` which contains the entire email. To obtain
-        the contents of the email, including all headers, simply use
-        :meth:`MailResponse.readContents`.
-    """
-    response = MailResponse(gpgContext)
-    response.writeHeaders(fromAddress, clientAddress, subject,
-                          inReplyTo=messageID)
-    response.writeBody(body)
-
-    # Only log the email text (including all headers) if SAFE_LOGGING is
-    # disabled:
-    if not safelog.safe_logging:
-        contents = response.readContents()
-        logging.debug("Email contents:\n%s" % contents)
-    else:
-        logging.debug("Email text for %r created." % clientAddress)
-    response.rewind()
-    return response
-
-
 class MailContext(object):
     """Helper object that holds information used by email subsystem.
 
@@ -231,155 +123,6 @@ class MailContext(object):
         return canon
 
 
-class MailResponse(object):
-    """Holds information for generating a response email for a request.
-
-    .. todo:: At some point, we may want to change this class to optionally
-        handle creating Multipart MIME encoding messages, so that we can
-        include attachments. (This would be useful for attaching our GnuPG
-        keyfile, for example, rather than simply pasting it into the body of
-        the email.)
-
-    :type _buff: unicode or buffer
-    :cvar _buff: Used internally to write lines for the response email into
-        the ``_mailfile``. The reason why both of these attributes have two
-        possible types is for the same Python-buggy reasons which require
-        :data:`~bridgedb.crypto.NEW_BUFFER_INTERFACE`.
-    :type mailfile: :class:`io.StringIO` or :class:`io.BytesIO`.
-    :cvar mailfile: An in-memory file for storing the formatted headers and
-        body of the response email.
-    """
-    _buff = buffer if NEW_BUFFER_INTERFACE else unicode
-    mailfile = io.BytesIO if NEW_BUFFER_INTERFACE else io.StringIO
-
-    def __init__(self, gpgContext=None):
-        """Create a response to an email we have recieved.
-
-        This class deals with correctly formatting text for the response email
-        headers and the response body into an instance of :cvar:`mailfile`.
-
-        :type gpgContext: None or ``gpgme.Context``
-        :param gpgContext: A pre-configured GPGME context. See
-            :meth:`bridgedb.crypto.getGPGContext` for obtaining a
-            pre-configured **gpgContext**. If given, and the ``Context`` has
-            been configured to sign emails, then a response email body string
-            given to :meth:`writeBody` will be signed before being written
-            into the ``mailfile``.
-        """
-        self.gpgContext = gpgContext
-        self.mailfile = self.mailfile()
-        self.closed = False
-
-    # These are methods and attributes for controlling I/O operations on our
-    # underlying ``mailfile``.
-
-    def close(self):
-        self.mailfile.close()
-        self.closed = True
-    close.__doc__ = mailfile.close.__doc__
-
-    # The following are custom methods to control reading and writing to the
-    # underlying ``mailfile``.
-
-    def readContents(self):
-        """Read the all the contents written thus far to the :cvar:`mailfile`,
-        and then :meth:`seek` to return to the original pointer position we
-        were at before this method was called.
-
-        :rtype: str
-        :returns: The entire contents of the :cvar:`mailfile`.
-        """
-        pointer = self.mailfile.tell()
-        self.mailfile.seek(0)
-        contents = self.mailfile.read()
-        self.mailfile.seek(pointer)
-        return contents
-
-    def rewind(self):
-        """Rewind to the very beginning of the :cvar:`mailfile`."""
-        logging.debug("Rewinding %s.mailfile..." % self.__class__.__name__)
-        self.mailfile.seek(0)
-
-    def write(self, line):
-        """Any **line** written to me will have ``'\r\n'`` appended to it."""
-        if line.find('\n') != -1:
-            # If **line** contains newlines, send it to :meth:`writelines` to
-            # break it up so that we can replace them:
-            logging.debug("Found newlines in %r. Calling writelines()." % line)
-            self.writelines(line)
-        else:
-            self.mailfile.write(self._buff(line + '\r\n'))
-            self.mailfile.flush()
-
-    def writelines(self, lines):
-        """Calls :meth:`write` for each line in **lines**."""
-        if isinstance(lines, basestring):
-            for ln in lines.split('\n'):
-                self.write(ln)
-        elif isinstance(lines, (list, tuple,)):
-            for ln in lines:
-                self.write(ln)
-
-    def writeHeaders(self, fromAddress, toAddress, subject=None,
-                     inReplyTo=None, includeMessageID=True,
-                     contentType='text/plain; charset="utf-8"', **kwargs):
-        """Write all headers into the response email.
-
-        :param str fromAddress: The email address for the ``From:`` header.
-        :param str toAddress: The email address for the ``To:`` header.
-        :type subject: None or str
-        :param subject: The ``Subject:`` header.
-        :type inReplyTo: None or str
-        :param inReplyTo: If set, an ``In-Reply-To:`` header will be
-            generated. This should be set to the ``Message-ID:`` header from
-            the client's original request email.
-        :param bool includeMessageID: If ``True``, generate and include a
-            ``Message-ID:`` header for the response.
-        :param str contentType: The ``Content-Type:`` header.
-        :kwargs: If given, the key will become the name of the header, and the
-            value will become the Contents of that header.
-        """
-        self.write("From: %s" % fromAddress)
-        self.write("To: %s" % toAddress)
-        if includeMessageID:
-            self.write("Message-ID: %s" % smtp.messageid())
-        if inReplyTo:
-            self.write("In-Reply-To: %s" % inReplyTo)
-        self.write("Content-Type: %s" % contentType)
-        self.write("Date: %s" % smtp.rfc822date())
-
-        if not subject:
-            subject = '[no subject]'
-        if not subject.lower().startswith('re'):
-            subject = "Re: " + subject
-        self.write("Subject: %s" % subject)
-
-        if kwargs:
-            for headerName, headerValue in kwargs.items():
-                headerName = headerName.capitalize()
-                headerName = headerName.replace(' ', '-')
-                headerName = headerName.replace('_', '-')
-                self.write("%s: %s" % (headerName, headerValue))
-
-        # The first blank line designates that the headers have ended:
-        self.write("\r\n")
-
-    def writeBody(self, body):
-        """Write the response body into the :cvar:`mailfile`.
-
-        If ``MailResponse.gpgContext`` is set, and signing is configured, the
-        **body** will be automatically signed before writing its contents into
-        the ``mailfile``.
-
-        :param str body: The body of the response email.
-        """
-        logging.info("Writing email body...")
-        if self.gpgContext:
-            logging.info("Attempting to sign email...")
-            body, _ = gpgSignMessage(self.gpgContext, body)
-        self.writelines(body)
-
-
 class MailMessage(object):
     """Plugs into the Twisted Mail and receives an incoming message.
 
@@ -441,161 +184,9 @@ class MailMessage(object):
         rawMessage.seek(0)
         return smtp.rfc822.Message(rawMessage)
 
-    def getClientAddress(self, incoming):
-        """Attempt to get the client's email address from an incoming email.
-
-        :type incoming: :api:`twisted.mail.smtp.rfc822.Message`
-        :param incoming: An incoming ``Message``, i.e. as returned from
-            :meth:`getIncomingMessage`.
-        :rtype: ``None`` or :api:`twisted.mail.smtp.Address`
-        :returns: The client's email ``Address``, if it originated from a
-            domain that we accept and the address was well-formed. Otherwise,
-            returns ``None``.
-        """
-        addrHeader = None
-        try: fromAddr = incoming.getaddr("From")[1]
-        except (IndexError, TypeError, AttributeError): pass
-        else: addrHeader = fromAddr
-
-        if not addrHeader:
-            logging.warn("No From header on incoming mail.")
-            try: senderHeader = incoming.getaddr("Sender")[1]
-            except (IndexError, TypeError, AttributeError): pass
-            else: addrHeader = senderHeader
-        if not addrHeader:
-            logging.warn("No Sender header on incoming mail.")
-        else:
-            try:
-                client = smtp.Address(addr.normalizeEmail(
-                    addrHeader,
-                    self.context.domainMap,
-                    self.context.domainRules))
-            except (UnsupportedDomain, BadEmail, smtp.AddressError) as error:
-                logging.warn(error)
-            else:
-                return client
-
-    def getMailFrom(self, incoming):
-        """Find our address in the recipients list of the **incoming** message.
-
-        :type incoming: :api:`twisted.mail.smtp.rfc822.Message`
-        :param incoming: An incoming ``Message``, i.e. as returned from
-            :meth:`getIncomingMessage`.
-        :rtype: str
-        :return: Our address from the recipients list. If we can't find it
-            return our default ``SMTP_FROM_ADDRESS`` from the config file.
-        """
-        logging.debug("Searching for our email address in 'To:' header...")
-
-        ours = None
-
-        try:
-            ourAddress = smtp.Address(self.context.fromAddr)
-            allRecipients = incoming.getaddrlist("To")
-
-            for _, addr in allRecipients:
-                recipient = smtp.Address(addr)
-                if not (ourAddress.domain in recipient.domain):
-                    logging.debug(("Not our domain (%s) or subdomain, skipping"
-                                   " email address: %s")
-                                  % (ourAddress.domain, str(recipient)))
-                    continue
-                # The recipient's username should at least start with ours,
-                # but it still might be a '+' address.
-                if not recipient.local.startswith(ourAddress.local):
-                    logging.debug(("Username doesn't begin with ours, skipping"
-                                   " email address: %s") % str(recipient))
-                    continue
-                # Ignore everything after the first '+', if there is one.
-                beforePlus = recipient.local.split('+', 1)[0]
-                if beforePlus == ourAddress.local:
-                    ours = str(recipient)
-            if not ours:
-                raise BadEmail(allRecipients)
 
-        except Exception as error:
-            logging.error(("Couldn't find our email address in incoming email "
-                           "headers: %r" % error))
-            # Just return the email address that we're configured to use:
-            ours = self.context.fromAddr
 
-        logging.debug("Found our email address: %s." % ours)
-        return ours
 
-    def getCanonicalDomain(self, domain):
-        try:
-            canonical = canonicalizeEmailDomain(domain, self.context.canon)
-        except (UnsupportedDomain, BadEmail) as error:
-            logging.warn(error)
-        else:
-            return canonical
-
-    def reply(self):
-        """Reply to an incoming email. Maybe.
-
-        If nothing is returned from either :func:`createResponseBody` or
-        :func:`generateResponse`, then the incoming email will not be
-        responded to at all. This can happen for several reasons, for example:
-        if the DKIM signature was invalid or missing, or if the incoming email
-        came from an unacceptable domain, or if there have been too many
-        emails from this client in the allotted time period.
-
-        :rtype: :api:`twisted.internet.defer.Deferred`
-        :returns: A ``Deferred`` which will callback when the response has
-            been successfully sent, or errback if an error occurred while
-            sending the email.
-        """
-        logging.info("Got an email; deciding whether to reply.")
-
-        def _replyEB(fail):  # pragma: no cover
-            """Errback for a :api:`twisted.mail.smtp.SMTPSenderFactory`.
-
-            :param fail: A :api:`twisted.python.failure.Failure` which occurred during
-            the transaction.
-            """
-            logging.debug("_replyToMailEB() called with %r" % fail)
-            error = fail.getTraceback() or "Unknown"
-            logging.error(error)
-
-        d = defer.Deferred()
-        d.addErrback(_replyEB)
-
-        incoming = self.getIncomingMessage()
-        recipient = self.getMailFrom(incoming)
-        client = self.getClientAddress(incoming)
-
-        if not client:
-            return d
-
-        if not self.fromCanonical:
-            self.fromCanonical = self.getCanonicalDomain(client.domain)
-        rules = self.context.domainRules.get(self.fromCanonical, [])
-        if not checkDKIM(incoming, rules):
-            return d
-
-        clientAddr = '@'.join([client.local, client.domain])
-        messageID = incoming.getheader("Message-ID", None)
-        subject = incoming.getheader("Subject", None)
-
-        # Look up the locale part in the 'To:' address, if there is one and
-        # get the appropriate Translation object:
-        lang = translations.getLocaleFromPlusAddr(recipient)
-        logging.info("Client requested email translation: %s" % lang)
-
-        body = createResponseBody(self.lines, self.context, client, lang)
-        if not body: return d  # The client was already warned.
-
-        response = generateResponse(self.context.fromAddr, clientAddr, body,
-                                    subject, messageID, self.context.gpgContext)
-        if not response: return d
-
-        logging.info("Sending reply to %s" % client)
-        factory = smtp.SMTPSenderFactory(self.context.smtpFromAddr, clientAddr,
-                                         response, d, retries=0, timeout=30)
-        reactor.connectTCP(self.context.smtpServerIP,
-                           self.context.smtpServerPort,
-                           factory)
-        return d
 
 
 class MailDelivery(object):






More information about the tor-commits mailing list