[tor-commits] [bridgedb/develop] Completely rewrite email servers.

isis at torproject.org isis at torproject.org
Wed May 14 03:42:27 UTC 2014


commit b11d1c513af7431f473d4443d37e40e1a407aee2
Author: Isis Lovecruft <isis at torproject.org>
Date:   Mon May 5 19:15:43 2014 +0000

    Completely rewrite email servers.
    
    The old bridgedb.EmailServer module has now been divided into several
    modules in the bridgedb.email package.
    
     * FIXES #5463 by adding the last touches for email signing.
    
     * FIXES #7547, #7550, and #8241 by adding a welcome email which can be
       received by sending an invalid request, or by saying "get help" in
       the body of the email.
    
     * FIXES #11475 by using the same "How To Use Your Bridge Lines" text
       for TBB/TorLauncher which is used for the HTTP distributor (on the
       website).
    
     * FIXES #11753 by making email responses translatable. A translated
       response can be requested, for example, for Farsi, by emailing
       mailto:bridges+fa at torproject.org.
---
 lib/bridgedb/EmailServer.py     |  483 -------------------------
 lib/bridgedb/Main.py            |    6 +-
 lib/bridgedb/email/request.py   |  153 ++++++++
 lib/bridgedb/email/server.py    |  746 +++++++++++++++++++++++++++++++++++++++
 lib/bridgedb/email/templates.py |  123 +++++++
 setup.py                        |    1 +
 6 files changed, 1026 insertions(+), 486 deletions(-)

diff --git a/lib/bridgedb/EmailServer.py b/lib/bridgedb/EmailServer.py
deleted file mode 100644
index 29787ea..0000000
--- a/lib/bridgedb/EmailServer.py
+++ /dev/null
@@ -1,483 +0,0 @@
-# -*- coding: utf-8 ; test-case-name: bridgedb.test.test_EmailServer -*-
-# BridgeDB by Nick Mathewson.
-# Copyright (c) 2007-2013, The Tor Project, Inc.
-# See LICENSE for licensing information
-
-"""This module implements the email interface to the bridge database."""
-
-from __future__ import unicode_literals
-
-from email import message
-import gettext
-import gpgme
-import io
-import logging
-import re
-import time
-
-from ipaddr import IPv4Address
-from ipaddr import IPv6Address
-
-from twisted.internet import defer
-from twisted.internet import reactor
-from twisted.internet.task import LoopingCall
-from twisted.mail import smtp
-
-from zope.interface import implements
-
-from bridgedb import Dist
-from bridgedb import I18n
-from bridgedb import safelog
-from bridgedb import translations
-from bridgedb.crypto import getGPGContext
-from bridgedb.crypto import gpgSignMessage
-from bridgedb.crypto import NEW_BUFFER_INTERFACE
-from bridgedb.Filters import filterBridgesByIP6
-from bridgedb.Filters import filterBridgesByIP4
-from bridgedb.Filters import filterBridgesByTransport
-from bridgedb.Filters import filterBridgesByNotBlockedIn
-from bridgedb.parse import addr
-from bridgedb.parse.addr import BadEmail
-from bridgedb.parse.addr import UnsupportedDomain
-from bridgedb.parse.addr import canonicalizeEmailDomain
-
-
-def getBridgeDBEmailAddrFromList(ctx, address_list):
-    """Loop through a list of (full name, email address) pairs and look up our
-       mail address. If our address isn't found (which can't happen), return
-       the default ctx from address so we can keep on working.
-    """
-    email = ctx.fromAddr
-    for _, address in address_list:
-        # Strip the @torproject.org part from the address
-        idx = address.find('@')
-        if idx != -1:
-            username = address[:idx]
-            # See if the user looks familiar. We do a 'find' instead
-            # of compare because we might have a '+' address here
-            if username.find(ctx.username) != -1:
-                email = address
-    return email
-
-def getMailResponse(lines, ctx):
-    """Given a list of lines from an incoming email message, and a
-       MailContext object, parse the email and decide what to do in response.
-       If we want to answer, return a 2-tuple containing the address that
-       will receive the response, and a readable filelike object containing
-       the response.  Return None,None if we shouldn't answer.
-    """
-    raw = io.StringIO()
-    raw.writelines([unicode('{0}\n'.format(line)) for line in lines])
-    raw.seek(0)
-
-    msg = smtp.rfc822.Message(raw)
-    # Extract data from the headers.
-    msgID = msg.getheader("Message-ID", None)
-    subject = msg.getheader("Subject", None) or "[no subject]"
-
-    fromHeader = msg.getaddr("From")
-    senderHeader = msg.getaddr("Sender")
-
-    clientAddrHeader = None
-    try:
-        clientAddrHeader = fromHeader[1]
-    except (IndexError, TypeError, AttributeError):
-        pass
-
-    if not clientAddrHeader:
-        logging.warn("No From header on incoming mail.")
-        try:
-            clientAddrHeader = senderHeader[1]
-        except (IndexError, TypeError, AttributeError):
-            pass
-
-    if not clientAddrHeader:
-        logging.warn("No Sender header on incoming mail.")
-        return None, None
-
-    try:
-        clientAddr = addr.normalizeEmail(clientAddrHeader,
-                                         ctx.cfg.EMAIL_DOMAIN_MAP,
-                                         ctx.cfg.EMAIL_DOMAIN_RULES)
-    except (UnsupportedDomain, BadEmail) as error:
-        logging.warn(error)
-        return None, None
-
-    # RFC822 requires at least one 'To' address
-    clientToList = msg.getaddrlist("To")
-    clientToAddr = getBridgeDBEmailAddrFromList(ctx, clientToList)
-
-    # Look up the locale part in the 'To:' address, if there is one and get
-    # the appropriate Translation object
-    lang = translations.getLocaleFromPlusAddr(clientToAddr)
-    t = translations.installTranslations(lang)
-
-    canon = ctx.cfg.EMAIL_DOMAIN_MAP
-    for domain, rule in ctx.cfg.EMAIL_DOMAIN_RULES.items():
-        if domain not in canon.keys():
-            canon[domain] = domain
-    for domain in ctx.cfg.EMAIL_DOMAINS:
-        canon[domain] = domain
-
-    try:
-        _, clientDomain = addr.extractEmailAddress(clientAddr.lower())
-        canonical = canonicalizeEmailDomain(clientDomain, canon)
-    except (UnsupportedDomain, BadEmail) as error:
-        logging.warn(error)
-        return None, None
-
-    rules = ctx.cfg.EMAIL_DOMAIN_RULES.get(canonical, [])
-
-    if 'dkim' in rules:
-        # getheader() returns the last of a given kind of header; we want
-        # to get the first, so we use getheaders() instead.
-        dkimHeaders = msg.getheaders("X-DKIM-Authentication-Results")
-        dkimHeader = "<no header>"
-        if dkimHeaders:
-            dkimHeader = dkimHeaders[0]
-        if not dkimHeader.startswith("pass"):
-            logging.info("Rejecting bad DKIM header on incoming email: %r "
-                         % dkimHeader)
-            return None, None
-
-    # Was the magic string included
-    #for ln in lines:
-    #    if ln.strip().lower() in ("get bridges", "subject: get bridges"):
-    #        break
-    #else:
-    #    logging.info("Got a mail from %r with no bridge request; dropping",
-    #                 clientAddr)
-    #    return None,None
-
-    # Figure out which bridges to send
-    unblocked = transport = ipv6 = skippedheaders = False
-    bridgeFilterRules = []
-    addressClass = None
-    for ln in lines:
-        # ignore all lines before the subject header
-        if "subject" in ln.strip().lower():
-            skippedheaders = True
-        if not skippedheaders:
-            continue
-
-        if "ipv6" in ln.strip().lower():
-            ipv6 = True
-        if "transport" in ln.strip().lower():
-            try:
-                transport = re.search("transport ([_a-zA-Z][_a-zA-Z0-9]*)",
-                        ln).group(1).strip()
-            except (TypeError, AttributeError):
-                transport = None
-            logging.debug("Got request for transport: %s" % transport)
-        if "unblocked" in ln.strip().lower():
-            try:
-                unblocked = re.search("unblocked ([a-zA-Z]{2,4})",
-                        ln).group(1).strip()
-            except (TypeError, AttributeError):
-                transport = None
-
-    if ipv6:
-        bridgeFilterRules.append(filterBridgesByIP6)
-        addressClass = IPv6Address
-    else:
-        bridgeFilterRules.append(filterBridgesByIP4)
-        addressClass = IPv4Address
-
-    if transport:
-        bridgeFilterRules = [filterBridgesByTransport(transport, addressClass)]
-
-    if unblocked:
-        rules.append(filterBridgesByNotBlockedIn(unblocked,
-                                                 addressClass,
-                                                 transport))
-
-    try:
-        interval = ctx.schedule.getInterval(time.time())
-        bridges = ctx.distributor.getBridgesForEmail(clientAddr,
-            interval, ctx.N,
-            countryCode=None,
-            bridgeFilterRules=bridgeFilterRules)
-
-    # Handle rate limited email
-    except Dist.TooSoonEmail as err:
-        logging.info("Got a mail too frequently; warning '%s': %s."
-                     % (clientAddr, err))
-        # MAX_EMAIL_RATE is in seconds, convert to hours
-        body = buildSpamWarningTemplate(t) % (Dist.MAX_EMAIL_RATE / 3600)
-        return composeEmail(ctx.fromAddr, clientAddr, subject, body, msgID,
-                            gpgContext=ctx.gpgContext)
-    except Dist.IgnoreEmail as err:
-        logging.info("Got a mail too frequently; ignoring '%s': %s."
-                     % (clientAddr, err))
-        return None, None
-    except BadEmail as err:
-        logging.info("Got a mail from a bad email address '%s': %s."
-                     % (clientAddr, err))
-        return None, None
-
-    answer = "(no bridges currently available)\n"
-    if bridges:
-        with_fp = ctx.cfg.EMAIL_INCLUDE_FINGERPRINTS
-        answer = "".join("  %s\n" % b.getConfigLine(
-            includeFingerprint=with_fp,
-            addressClass=addressClass,
-            transport=transport,
-            request=clientAddr) for b in bridges)
-
-    body = buildMessageTemplate(t) % answer
-    # Generate the message.
-    return composeEmail(ctx.fromAddr, clientAddr, subject, body, msgID,
-                        gpgContext=ctx.gpgContext)
-
-def buildMessageTemplate(t):
-    msg_template = t.gettext(I18n.BRIDGEDB_TEXT[5]) + "\n\n" \
-                   + t.gettext(I18n.BRIDGEDB_TEXT[0]) + "\n\n" \
-                   + "%s\n" \
-                   + t.gettext(I18n.BRIDGEDB_TEXT[1]) + "\n\n" \
-                   + t.gettext(I18n.BRIDGEDB_TEXT[2]) + "\n\n" \
-                   + t.gettext(I18n.BRIDGEDB_TEXT[3]) + "\n\n" \
-                   + t.gettext(I18n.BRIDGEDB_TEXT[17])+ "\n\n"
-    # list supported commands, e.g. ipv6, transport
-    msg_template = msg_template \
-                   + "  " + t.gettext(I18n.BRIDGEDB_TEXT[18])+ "\n" \
-                   + "  " + t.gettext(I18n.BRIDGEDB_TEXT[19])+ "\n\n" \
-                   + t.gettext(I18n.BRIDGEDB_TEXT[6]) + "\n\n"
-    return msg_template
-
-def buildSpamWarningTemplate(t):
-    msg_template = t.gettext(I18n.BRIDGEDB_TEXT[5]) + "\n\n" \
-                   + t.gettext(I18n.BRIDGEDB_TEXT[10]) + "\n\n" \
-                   + "%s " \
-                   + t.gettext(I18n.BRIDGEDB_TEXT[11]) + "\n\n" \
-                   + t.gettext(I18n.BRIDGEDB_TEXT[12]) + "\n\n"
-    return msg_template
-
-def _ebReplyToMailFailure(fail):
-    """Errback for a :api:`twisted.mail.smtp.SMTPSenderFactory`.
-
-    :param fail: A :api:`twisted.python.failure.Failure` which occurred during
-        the transaction.
-    """
-    logging.debug("EmailServer._ebReplyToMailFailure() called with %r" % fail)
-    error = fail.getErrorMessage() or "unknown failure."
-    logging.exception("replyToMail Failure: %s" % error)
-    return None
-
-def replyToMail(lines, ctx):
-    """Reply to an incoming email. Maybe.
-
-    If no `response` is returned from :func:`getMailResponse`, 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.
-
-    :param list lines: A list of lines from an incoming email message.
-    :type ctx: :class:`MailContext`
-    :param ctx: The configured context for the email server.
-    :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.")
-    sendToUser, response = getMailResponse(lines, ctx)
-
-    d = defer.Deferred()
-
-    if response is None:
-        logging.debug("We don't feel like talking to %s." % sendToUser)
-        return d
-
-    response.seek(0)
-    logging.info("Sending reply to %s" % sendToUser)
-    factory = smtp.SMTPSenderFactory(ctx.smtpFromAddr, sendToUser,
-                                     response, d, retries=0, timeout=30)
-    d.addErrback(_ebReplyToMailFailure)
-    reactor.connectTCP(ctx.smtpServer, ctx.smtpPort, factory)
-    return d
-
-def composeEmail(fromAddr, clientAddr, subject, body,
-                 msgID=None, gpgContext=None):
-
-    if not subject.startswith("Re:"):
-        subject = "Re: %s" % subject
-
-    msg = smtp.rfc822.Message(io.StringIO())
-    msg.setdefault("From", fromAddr)
-    msg.setdefault("To", clientAddr)
-    msg.setdefault("Message-ID", smtp.messageid())
-    msg.setdefault("Subject", subject)
-    if msgID:
-        msg.setdefault("In-Reply-To", msgID)
-    msg.setdefault("Date", smtp.rfc822date())
-    msg.setdefault('Content-Type', 'text/plain; charset="utf-8"')
-    headers = [': '.join(m) for m in msg.items()]
-
-    if NEW_BUFFER_INTERFACE:
-        mail = io.BytesIO()
-        buff = buffer
-    else:
-        mail = io.StringIO()
-        buff = unicode
-
-    mail.writelines(buff("\r\n".join(headers)))
-    mail.writelines(buff("\r\n"))
-    mail.writelines(buff("\r\n"))
-
-    if not gpgContext:
-        mail.write(buff(body))
-    else:
-        signature, siglist = gpgSignMessage(gpgContext, body)
-        if signature:
-            mail.writelines(buff(signature))
-    mail.seek(0)
-
-    # Only log the email text (including all headers) if SAFE_LOGGING is
-    # disabled:
-    if not safelog.safe_logging:
-        logging.debug("Email contents:\n\n%s" % mail.read())
-        mail.seek(0)
-    else:
-        logging.debug("Email text for %r created." % clientAddr)
-
-    return clientAddr, mail
-
-
-class MailContext(object):
-    """Helper object that holds information used by email subsystem."""
-
-    def __init__(self, cfg, dist, sched):
-        # Reject any RCPT TO lines that aren't to this user.
-        self.username = (cfg.EMAIL_USERNAME or "bridges")
-        # Reject any mail longer than this.
-        self.maximumSize = 32*1024
-        # Use this server for outgoing mail.
-        self.smtpServer = (cfg.EMAIL_SMTP_HOST or "127.0.0.1")
-        self.smtpPort = (cfg.EMAIL_SMTP_PORT or 25)
-        # Use this address in the MAIL FROM line for outgoing mail.
-        self.smtpFromAddr = (cfg.EMAIL_SMTP_FROM_ADDR or
-                             "bridges at torproject.org")
-        # Use this address in the "From:" header for outgoing mail.
-        self.fromAddr = (cfg.EMAIL_FROM_ADDR or
-                         "bridges at torproject.org")
-        # An EmailBasedDistributor object
-        self.distributor = dist
-        # An IntervalSchedule object
-        self.schedule = sched
-        # The number of bridges to send for each email.
-        self.N = cfg.EMAIL_N_BRIDGES_PER_ANSWER
-
-        # Initialize a gpg context or set to None for backward compatibliity.
-        self.gpgContext = getGPGContext(cfg)
-
-        self.cfg = cfg
-
-class MailMessage(object):
-    """Plugs into the Twisted Mail and receives an incoming message."""
-    implements(smtp.IMessage)
-
-    def __init__(self, ctx):
-        """Create a new MailMessage from a MailContext."""
-        self.ctx = ctx
-        self.lines = []
-        self.nBytes = 0
-        self.ignoring = False
-
-    def lineReceived(self, line):
-        """Called when we get another line of an incoming message."""
-        self.nBytes += len(line)
-        if not safelog.safe_logging:
-            logging.debug("> %s", line.rstrip("\r\n"))
-        if self.nBytes > self.ctx.maximumSize:
-            self.ignoring = True
-        else:
-            self.lines.append(line)
-
-    def eomReceived(self):
-        """Called when we receive the end of a message."""
-        if not self.ignoring:
-            replyToMail(self.lines, self.ctx)
-        return defer.succeed(None)
-
-    def connectionLost(self):
-        """Called if we die partway through reading a message."""
-        pass
-
-class MailDelivery(object):
-    """Plugs into Twisted Mail and handles SMTP commands."""
-    implements(smtp.IMessageDelivery)
-
-    def setBridgeDBContext(self, ctx):
-        self.ctx = ctx
-
-    def receivedHeader(self, helo, origin, recipients):
-        """Create the ``Received:`` header for an incoming email.
-
-        :type helo: tuple
-        :param helo: The lines received during SMTP client HELO.
-        :type origin: :api:`twisted.mail.smtp.Address`
-        :param origin: The email address of the sender.
-        :type recipients: list
-        :param recipients: A list of :api:`twisted.mail.smtp.User` instances.
-        """
-        cameFrom = "%s (%s [%s])" % (helo[0] or origin, helo[0], helo[1])
-        cameFor = ', '.join(["<{0}>".format(recp.dest) for recp in recipients])
-        hdr = str("Received: from %s for %s; %s" % (cameFrom, cameFor,
-                                                    smtp.rfc822date()))
-        return hdr
-
-    def validateFrom(self, helo, origin):
-        return origin
-
-    def validateTo(self, user):
-        """If the local user that was addressed isn't our configured local user
-        or doesn't contain a '+' with a prefix matching the local configured
-        user: Yell.
-        """
-        u = user.dest.local
-        # Hasplus? If yes, strip '+foo'
-        idx = u.find('+')
-        if idx != -1:
-            u = u[:idx]
-        if u != self.ctx.username:
-            raise smtp.SMTPBadRcpt(user)
-        return lambda: MailMessage(self.ctx)
-
-class MailFactory(smtp.SMTPFactory):
-    """Plugs into Twisted Mail; creates a new MailDelivery whenever we get
-       a connection on the SMTP port."""
-
-    def __init__(self, *a, **kw):
-        smtp.SMTPFactory.__init__(self, *a, **kw)
-        self.delivery = MailDelivery()
-
-    def setBridgeDBContext(self, ctx):
-        self.ctx = ctx
-        self.delivery.setBridgeDBContext(ctx)
-
-    def buildProtocol(self, addr):
-        p = smtp.SMTPFactory.buildProtocol(self, addr)
-        p.delivery = self.delivery
-        return p
-
-def addSMTPServer(cfg, dist, sched):
-    """Set up a smtp server.
-         cfg -- a configuration object from Main.  We use these options:
-                EMAIL_BIND_IP
-                EMAIL_PORT
-                EMAIL_N_BRIDGES_PER_ANSWER
-                EMAIL_DOMAIN_RULES
-         dist -- an EmailBasedDistributor object.
-         sched -- an IntervalSchedule object.
-    """
-    ctx = MailContext(cfg, dist, sched)
-    factory = MailFactory()
-    factory.setBridgeDBContext(ctx)
-    ip = cfg.EMAIL_BIND_IP or ""
-    reactor.listenTCP(cfg.EMAIL_PORT, factory, interface=ip)
-    # Set up a LoopingCall to run every 30 minutes and forget old email times.
-    lc = LoopingCall(dist.cleanDatabase)
-    lc.start(1800, now=False)
-    return factory
diff --git a/lib/bridgedb/Main.py b/lib/bridgedb/Main.py
index 90a03ef..1b4420e 100644
--- a/lib/bridgedb/Main.py
+++ b/lib/bridgedb/Main.py
@@ -443,7 +443,7 @@ def startup(options):
 
     state = persistent.State(config=config)
 
-    from bridgedb import EmailServer
+    from bridgedb.email.server import addServer as addSMTPServer
     from bridgedb import HTTPServer
 
     # Load the master key, or create a new one.
@@ -596,7 +596,7 @@ def startup(options):
     if config.EMAIL_DIST and config.EMAIL_SHARE:
         #emailSchedule = Time.IntervalSchedule("day", 1)
         emailSchedule = Time.NoSchedule()
-        EmailServer.addSMTPServer(config, emailDistributor, emailSchedule)
+        addSMTPServer(config, emailDistributor, emailSchedule)
 
     # Actually run the servers.
     try:
@@ -623,7 +623,7 @@ def runSubcommand(options, config):
     """
     # Make sure that the runner module is only imported after logging is set
     # up, otherwise we run into the same logging configuration problem as
-    # mentioned above with the EmailServer and HTTPServer.
+    # mentioned above with the email.server and HTTPServer.
     from bridgedb import runner
 
     statuscode = 0
diff --git a/lib/bridgedb/email/__init__.py b/lib/bridgedb/email/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lib/bridgedb/email/request.py b/lib/bridgedb/email/request.py
new file mode 100644
index 0000000..e5b1fb9
--- /dev/null
+++ b/lib/bridgedb/email/request.py
@@ -0,0 +1,153 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import print_function
+from __future__ import unicode_literals
+
+import logging
+import re
+
+from bridgedb import bridgerequest
+from bridgedb.Dist import EmailRequestedHelp
+from bridgedb.Dist import EmailRequestedKey
+
+
+#: A regular expression for matching the Pluggable Transport method TYPE in
+#: emailed requests for Pluggable Transports.
+TRANSPORT_REGEXP = ".*transport ([a-z][_a-z0-9]*)"
+TRANSPORT_PATTERN = re.compile(TRANSPORT_REGEXP)
+
+#: A regular expression that matches country codes in requests for unblocked
+#: bridges.
+UNBLOCKED_REGEXP = ".*unblocked ([a-z]{2,4})"
+UNBLOCKED_PATTERN = re.compile(UNBLOCKED_REGEXP)
+
+
+def determineBridgeRequestOptions(lines):
+    """Figure out which :class:`Bridges.BridgeFilter`s to apply, or offer help.
+
+    .. note:: If any ``'transport TYPE'`` was requested, or bridges not
+        blocked in a specific CC (``'unblocked CC'``), then the ``TYPE``
+        and/or ``CC`` will *always* be stored as a *lowercase* string.
+
+    :param list lines: A list of lines from an email, including the headers.
+    :raises EmailRequestedHelp: if the client requested help.
+    :raises EmailRequestedKey: if the client requested our GnuPG key.
+    :rtype: :class:`EmailBridgeRequest`
+    :returns: A :class:`~bridgerequst.BridgeRequest` with all of the requested
+        parameters set. The returned ``BridgeRequest`` will have already had
+        its filters generated via :meth:`~EmailBridgeRequest.generateFilters`.
+    """
+    request = EmailBridgeRequest()
+    skippedHeaders = False
+
+    for line in lines:
+        line = line.strip().lower()
+        # Ignore all lines before the first empty line:
+        if not line: skippedHeaders = True
+        if not skippedHeaders: continue
+
+        if ("help" in line) or ("halp" in line):
+            raise EmailRequestedHelp("Client requested help.")
+
+        if "get" in line:
+            request.isValid(True)
+            logging.debug("Email request was valid.")
+        if "key" in line:
+            request.wantsKey(True)
+            raise EmailRequestedKey("Email requested a copy of our GnuPG key.")
+        if "ipv6" in line:
+            request.withIPv6()
+        if "transport" in line:
+            request.withPluggableTransportType(line)
+        if "unblocked" in line:
+            request.withoutBlockInCountry(line)
+
+    logging.debug("Generating hashring filters for request.")
+    request.generateFilters()
+    return request
+
+
+class EmailBridgeRequest(bridgerequest.BridgeRequestBase):
+    """We received a request for bridges through the email distributor."""
+
+    def __init__(self):
+        """Process a new bridge request received through the
+        :class:`~bridgedb.Dist.EmailBasedDistributor`.
+        """
+        super(EmailBridgeRequest, self).__init__()
+        self._isValid = False
+        self._wantsKey = False
+
+    def isValid(self, valid=None):
+        """Get or set the validity of this bridge request.
+
+        If called without parameters, this method will return the current
+        state, otherwise (if called with the **valid** parameter), it will set
+        the current state of validity for this request.
+
+        :param bool valid: If given, set the validity state of this
+            request. Otherwise, get the current state.
+        """
+        if valid is not None:
+            self._isValid = bool(valid)
+        return self._isValid
+
+    def wantsKey(self, wantsKey=None):
+        """Get or set whether this bridge request wanted our GnuPG key.
+
+        If called without parameters, this method will return the current
+        state, otherwise (if called with the **wantsKey** parameter set), it
+        will set the current state for whether or not this request wanted our
+        key.
+
+        :param bool wantsKey: If given, set the validity state of this
+            request. Otherwise, get the current state.
+        """
+        if wantsKey is not None:
+            self._wantsKey = bool(wantsKey)
+        return self._wantsKey
+
+    def withoutBlockInCountry(self, line):
+        """This request was for bridges not blocked in **country**.
+
+        Add any country code found in the **line** to the list of
+        ``notBlockedIn``. Currently, a request for a transport is recognized
+        if the email line contains the ``'unblocked'`` command.
+
+        :param str country: The line from the email wherein the client
+            requested some type of Pluggable Transport.
+        """
+        unblocked = None
+
+        logging.debug("Parsing 'unblocked' line: %r" % line)
+        try:
+            unblocked = UNBLOCKED_PATTERN.match(line).group(1)
+        except (TypeError, AttributeError):
+            pass
+
+        if unblocked:
+            self.notBlockedIn.append(unblocked)
+            logging.info("Email requested bridges not blocked in: %r"
+                         % unblocked)
+
+    def withPluggableTransportType(self, line):
+        """This request included a specific Pluggable Transport identifier.
+
+        Add any Pluggable Transport method TYPE found in the **line** to the
+        list of ``transports``. Currently, a request for a transport is
+        recognized if the email line contains the ``'transport'`` command.
+
+        :param str line: The line from the email wherein the client
+            requested some type of Pluggable Transport.
+        """
+        transport = None
+        logging.debug("Parsing 'transport' line: %r" % line)
+
+        try:
+            transport = TRANSPORT_PATTERN.match(line).group(1)
+        except (TypeError, AttributeError):
+            pass
+
+        if transport:
+            self.transports.append(transport)
+            logging.info("Email requested transport type: %r" % transport)
diff --git a/lib/bridgedb/email/server.py b/lib/bridgedb/email/server.py
new file mode 100644
index 0000000..97ddcde
--- /dev/null
+++ b/lib/bridgedb/email/server.py
@@ -0,0 +1,746 @@
+# -*- coding: utf-8 ; test-case-name: bridgedb.test.test_email_server -*-
+#_____________________________________________________________________________
+#
+# 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
+#_____________________________________________________________________________
+
+
+"""Servers which interface with clients and distribute bridges over SMTP."""
+
+from __future__ import unicode_literals
+
+import logging
+import io
+import time
+
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.internet.task import LoopingCall
+from twisted.mail import smtp
+
+from zope.interface import implements
+
+from bridgedb import safelog
+from bridgedb import translations
+from bridgedb.crypto import getGPGContext
+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 templates
+from bridgedb.email import request
+from bridgedb.parse import addr
+from bridgedb.parse.addr import BadEmail
+from bridgedb.parse.addr import UnsupportedDomain
+from bridgedb.parse.addr import canonicalizeEmailDomain
+
+
+def checkDKIM(message, rules):
+    """Check the DKIM verification results header.
+
+    This check is only run if the incoming email, **message**, originated from
+    a domain for which we're configured (in the ``EMAIL_DOMAIN_RULES``
+    dictionary in the config file) to check DKIM verification results for.
+
+    :type message: :api:`twisted.mail.smtp.rfc822.Message`
+    :param message: The incoming client request email, including headers.
+    :param dict rules: The list of configured ``EMAIL_DOMAIN_RULES`` for the
+        canonical domain which the client's email request originated from.
+
+    :rtype: bool
+    :returns: ``False`` if:
+        1. We're supposed to expect and check the DKIM headers for the
+           client's email provider domain.
+        2. Those headers were *not* okay.
+        Otherwise, returns ``True``.
+    """
+    if 'dkim' in rules:
+        # getheader() returns the last of a given kind of header; we want
+        # to get the first, so we use getheaders() instead.
+        dkimHeaders = message.getheaders("X-DKIM-Authentication-Results")
+        dkimHeader = "<no header>"
+        if dkimHeaders:
+            dkimHeader = dkimHeaders[0]
+        if not dkimHeader.startswith("pass"):
+            logging.info("Rejecting bad DKIM header on incoming email: %r "
+                         % dkimHeader)
+            return False
+    return True
+
+def createResponseBody(lines, context, toAddress, 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.
+    :param str toAddress: The rfc:`2821` 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 %r was invalid."
+                                     % toAddress)
+
+        # Otherwise they must have requested bridges:
+        interval = context.schedule.getInterval(time.time())
+        bridges = context.distributor.getBridgesForEmail(
+            toAddress,
+            interval,
+            context.nBridges,
+            countryCode=None,
+            bridgeFilterRules=bridgeRequest.filters)
+    except EmailRequestedHelp as error:
+        logging.info(error)
+        return templates.buildWelcomeText(t)
+    except EmailRequestedKey as error:
+        logging.info(error)
+        return templates.buildKeyfile(t)
+    except TooSoonEmail as error:
+        logging.info("Got a mail too frequently: %s." % error)
+        return templates.buildSpamWarning(t)
+    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=toAddress) for b in bridges)
+        return templates.buildMessage(t) % answer
+
+def generateResponse(fromAddress, clientAddress, subject, body,
+                     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.read`.
+    """
+    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."""
+
+    def __init__(self, config, distributor, schedule):
+        """DOCDOC
+
+        :ivar str username: Reject any RCPT TO lines that aren't to this
+            user. See the ``EMAIL_USERNAME`` option in the config file.
+            (default: ``'bridges'``)
+        :ivar int maximumSize: Reject any incoming emails longer than
+            this size (in bytes). (default: 3084 bytes).
+        :ivar int smtpPort: The port to use for outgoing SMTP.
+        :ivar str smtpServer: The IP address to use for outgoing SMTP.
+        :ivar str smtpFromAddr: Use this address in the raw SMTP ``MAIL FROM``
+            line for outgoing mail. (default: ``bridges at torproject.org``)
+        :ivar str fromAddr: Use this address in the email :header:`From:`
+            line for outgoing mail. (default: ``bridges at torproject.org``)
+        :ivar int nBridges: The number of bridges to send for each email.
+        :ivar gpgContext: A ``gpgme.GpgmeContext`` (as created by
+            :func:`bridgedb.crypto.getGPGContext`), or None if we couldn't
+            create a proper GPGME context for some reason.
+
+        :type config: :class:`bridgedb.persistent.Conf`
+        :type distributor: :class:`bridgedb.Dist.EmailBasedDistributor`.
+        :param distributor: DOCDOC
+        :type schedule: :class:`bridgedb.Time.IntervalSchedule`.
+        :param schedule: DOCDOC
+        """
+        self.config = config
+        self.distributor = distributor
+        self.schedule = schedule
+
+        self.maximumSize = 32*1024
+        self.includeFingerprints = config.EMAIL_INCLUDE_FINGERPRINTS
+        self.nBridges = config.EMAIL_N_BRIDGES_PER_ANSWER
+
+        self.username = (config.EMAIL_USERNAME or "bridges")
+        self.fromAddr = (config.EMAIL_FROM_ADDR or "bridges at torproject.org")
+        self.smtpFromAddr = (config.EMAIL_SMTP_FROM_ADDR or self.fromAddr)
+        self.smtpServerPort = (config.EMAIL_SMTP_PORT or 25)
+        self.smtpServerIP = (config.EMAIL_SMTP_HOST or "127.0.0.1")
+
+        self.domainRules = config.EMAIL_DOMAIN_RULES or {}
+        self.domainMap = config.EMAIL_DOMAIN_MAP or {}
+        self.canon = self.buildCanonicalDomainMap()
+
+        self.gpgContext = getGPGContext(config)
+
+    def buildCanonicalDomainMap(self):
+        """Build a map for all email provider domains from which we will accept
+        emails to their canonical domain name.
+
+        .. note:: Be sure that ``MailContext.domainRules`` and
+            ``MailContext.domainMap`` are set appropriately before calling
+            this method.
+
+        This method is automatically called during initialisation, and the
+        resulting domain map is stored as ``MailContext.canon``.
+
+        :rtype: dict
+        :returns: A dictionary which maps all domains and subdomains which we
+            accept emails from to their second-level, canonical domain names.
+        """
+        canon = self.domainMap
+        for domain, rule in self.domainRules.items():
+            if domain not in canon.keys():
+                canon[domain] = domain
+        for domain in self.config.EMAIL_DOMAINS:
+            canon[domain] = domain
+        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.
+    """
+
+    implements(smtp.IMessage)
+
+    _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__
+
+    def flush(self, *args, **kwargs): self.mailfile.flush(*args, **kwargs)
+    flush.__doc__ = mailfile.flush.__doc__
+
+    def read(self, *args, **kwargs):
+        self.mailfile.read(*args, **kwargs)
+    read.__doc__ = mailfile.read.__doc__
+     
+    def readline(self, *args, **kwargs):
+        self.mailfile.readline(*args, **kwargs)
+    readline.__doc__ = mailfile.readline.__doc__
+
+    def readlines(self, *args, **kwargs):
+        self.mailfile.readlines(*args, **kwargs)
+    readlines.__doc__ = mailfile.readlines.__doc__
+
+    def seek(self, *args, **kwargs):
+        self.mailfile.seek(*args, **kwargs)
+    seek.__doc__ = mailfile.seek.__doc__
+    
+    def tell(self, *args, **kwargs):
+        self.mailfile.tell(*args, **kwargs)
+    tell.__doc__ = mailfile.tell.__doc__
+    
+    def truncate(self, *args, **kwargs):
+        self.mailfile.truncate(*args, **kwargs)
+    truncate.__doc__ = mailfile.truncate.__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`."""
+        self.seek(0)
+
+    def write(self, line):
+        """Any **line** written to me will have ``'\r\n'`` appended to it."""
+        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.
+        """
+        if self.gpgContext:
+            body, _ = gpgSignMessage(self.gpgContext, body)
+        self.writelines(body)
+
+    # The following methods implement the IMessage interface.
+
+    def lineReceived(self, line):
+        """Called when we receive a line from an underlying transport."""
+        self.write(line)
+
+    def eomRecieved(self):
+        """Called when we receive an EOM.
+
+        :rtype: :api:`twisted.internet.defer.Deferred`
+        :returns: A ``Deferred`` which has already been callbacked with the
+            entire response email contents retrieved from
+            :meth:`readContents`.
+        """
+        contents = self.readContents()
+        if not self.closed:
+            self.connectionLost()
+        return defer.succeed(contents)
+
+    def connectionLost(self):
+        """Called if we die partway through reading a message.
+
+        Truncate the :cvar:`mailfile` to null length, then close it.
+        """
+        self.mailfile.truncate(0)
+        self.mailfile.close()
+
+
+class MailMessage(object):
+    """Plugs into the Twisted Mail and receives an incoming message."""
+    implements(smtp.IMessage)
+
+    def __init__(self, context, fromCanonical=None):
+        """Create a new MailMessage from a MailContext.
+
+        :param list lines: A list of lines from an incoming email message.
+        :type context: :class:`MailContext`
+        :param context: The configured context for the email server.
+        :type canonicalFrom: str or None
+        :param canonicalFrom: The canonical domain which this message was
+            received from. For example, if ``'gmail.com'`` is the configured
+            canonical domain for ``'googlemail.com'`` and a message is
+            received from the latter domain, then this would be set to the
+            former.
+        """
+        self.context = context
+        self.fromCanonical = fromCanonical
+        self.lines = []
+        self.nBytes = 0
+        self.ignoring = False
+
+    def lineReceived(self, line):
+        """Called when we get another line of an incoming message."""
+        self.nBytes += len(line)
+        if self.nBytes > self.context.maximumSize:
+            self.ignoring = True
+        else:
+            self.lines.append(line)
+        if not safelog.safe_logging:
+            logging.debug("> %s", line.rstrip("\r\n"))
+
+    def eomReceived(self):
+        """Called when we receive the end of a message."""
+        if not self.ignoring:
+            self.reply()
+        return defer.succeed(None)
+
+    def connectionLost(self):
+        """Called if we die partway through reading a message."""
+        pass
+
+    def getIncomingMessage(self):
+        """Create and parse an :rfc:`2822` message object for all ``lines``
+        received thus far.
+
+        :rtype: :api:`twisted.mail.smtp.rfc822.Message`.
+        :returns: A ``Message`` comprised of all lines received thus far.
+        """
+        rawMessage = io.StringIO()
+        rawMessage.writelines([unicode('{0}\n'.format(ln)) for ln in self.lines])
+        rawMessage.seek(0)
+        return smtp.rfc822.Message(rawMessage)
+
+    def getClientAddress(self, incoming):
+        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 getRecipient(self, incoming):
+        """Find our **address** in a list of ``('NAME', '<ADDRESS>')`` pairs.
+
+        If our address isn't found (which can't happen), return the default
+        context :header:`From` address so we can keep on working.
+
+        :param str address: Our email address, as set in the
+            ``EMAIL_SMTP_FROM`` config option.
+        :param list addressList: A list of 2-tuples of strings, the first
+            string is a full name, username, common name, etc., and the second
+            is the entity's email address.
+        """
+        address = self.context.fromAddr
+        addressList = incoming.getaddrlist("To")
+
+        try:
+            ours = smtp.Address(address)
+        except smtp.AddressError as error:
+            logging.warn("Our address seems invalid: %r" % address)
+            logging.warn(error)
+        else:
+            for _, addr in addressList:
+                try:
+                    maybeOurs = smtp.Address(addr)
+                except smtp.AddressError:
+                    pass
+                else:
+                    # See if the user looks familiar. We do a 'find' instead of
+                    # compare because we might have a '+' address here.
+                    if maybeOurs.local.find(ours.local) != -1:
+                        return '@'.join([maybeOurs.local, maybeOurs.domain])
+        return address
+
+    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 no `response` is returned from :func:`createMailResponse`, 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):
+            """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.getRecipient(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) or "[no subject]"
+
+        # 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, clientAddr, lang)
+        if not body: return d  # The client was already warned.
+
+        response = generateResponse(self.context.fromAddr, clientAddr, subject,
+                                    body, 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):
+    """Plugs into Twisted Mail and handles SMTP commands."""
+    implements(smtp.IMessageDelivery)
+
+    def setBridgeDBContext(self, context):
+        self.context = context
+        self.fromCanonical = None
+
+    def receivedHeader(self, helo, origin, recipients):
+        """Create the ``Received:`` header for an incoming email.
+
+        :type helo: tuple
+        :param helo: The lines received during SMTP client HELO.
+        :type origin: :api:`twisted.mail.smtp.Address`
+        :param origin: The email address of the sender.
+        :type recipients: list
+        :param recipients: A list of :api:`twisted.mail.smtp.User` instances.
+        """
+        cameFrom = "%s (%s [%s])" % (helo[0] or origin, helo[0], helo[1])
+        cameFor = ', '.join(["<{0}>".format(recp.dest) for recp in recipients])
+        hdr = str("Received: from %s for %s; %s"
+                  % (cameFrom, cameFor, smtp.rfc822date()))
+        return hdr
+
+    def validateFrom(self, helo, origin):
+        try:
+            logging.debug("ORIGIN: %r" % repr(origin.addrstr))
+            canonical = canonicalizeEmailDomain(origin.domain,
+                                                self.context.canon)
+        except UnsupportedDomain as error:
+            logging.info(error)
+            raise smtp.SMTPBadSender(origin.domain)
+        except Exception as error:
+            logging.exception(error)
+        else:
+            logging.debug("Got canonical domain: %r" % canonical)
+            self.fromCanonical = canonical
+        return origin  # This method *cannot* return None, or it'll cause a 503.
+
+    def validateTo(self, user):
+        """If the local user that was addressed isn't our configured local user
+        or doesn't contain a '+' with a prefix matching the local configured
+        user: Yell.
+        """
+        u = user.dest.local
+        # Hasplus? If yes, strip '+foo'
+        idx = u.find('+')
+        if idx != -1:
+            u = u[:idx]
+        if u != self.context.username:
+            raise smtp.SMTPBadRcpt(user)
+        return lambda: MailMessage(self.context, self.fromCanonical)
+
+
+class MailFactory(smtp.SMTPFactory):
+    """Plugs into Twisted Mail; creates a new MailDelivery whenever we get
+       a connection on the SMTP port."""
+
+    def __init__(self, context=None, **kw):
+        smtp.SMTPFactory.__init__(self, **kw)
+        self.delivery = MailDelivery()
+        if context:
+            self.setBridgeDBContext(context)
+
+    def setBridgeDBContext(self, context):
+        self.context = context
+        self.delivery.setBridgeDBContext(context)
+
+    def buildProtocol(self, addr):
+        p = smtp.SMTPFactory.buildProtocol(self, addr)
+        p.delivery = self.delivery
+        return p
+
+
+def addServer(config, distributor, schedule):
+    """Set up a SMTP server for responding to requests for bridges.
+
+    :param config: A configuration object from Main. We use these
+        options::
+            EMAIL_BIND_IP
+            EMAIL_PORT
+            EMAIL_N_BRIDGES_PER_ANSWER
+            EMAIL_DOMAIN_RULES
+    :type distributor: :class:`bridgedb.Dist.EmailBasedDistributor`
+    :param dist: A distributor which will handle database interactions, and
+        will decide which bridges to give to who and when.
+    :type schedule: :class:`bridgedb.Time.IntervalSchedule`
+    :param schedule: The schedule. XXX: Is this even used?
+    """
+    context = MailContext(config, distributor, schedule)
+    factory = MailFactory(context)
+
+    addr = config.EMAIL_BIND_IP or ""
+    port = config.EMAIL_PORT
+
+    reactor.listenTCP(port, factory, interface=addr)
+
+    # Set up a LoopingCall to run every 30 minutes and forget old email times.
+    lc = LoopingCall(distributor.cleanDatabase)
+    lc.start(1800, now=False)
+
+    return factory
diff --git a/lib/bridgedb/email/templates.py b/lib/bridgedb/email/templates.py
new file mode 100644
index 0000000..6c25038
--- /dev/null
+++ b/lib/bridgedb/email/templates.py
@@ -0,0 +1,123 @@
+# -*- coding: utf-8 ; test-case-name: bridgedb.test.test_email_templates -*-
+#_____________________________________________________________________________
+#
+# This file is part of BridgeDB, a Tor bridge distribution system.
+#
+# :authors: Isis Lovecruft <isis at torproject.org> 0xA3ADB67A2CDB8B35
+#           please also see AUTHORS file
+# :copyright: (c) 2007-2014, The Tor Project, Inc.
+#             (c) 2013-2014, Isis Lovecruft
+# :license: see LICENSE for licensing information
+#_____________________________________________________________________________
+
+"""Templates for formatting emails sent out by the email distributor."""
+
+from __future__ import print_function
+from __future__ import unicode_literals
+
+import logging
+import os
+
+from bridgedb import strings
+from bridgedb.Dist import MAX_EMAIL_RATE
+from bridgedb.HTTPServer import TEMPLATE_DIR
+
+
+def buildCommands(template):
+    # Tell them about the various email commands:
+    cmdlist = []
+    cmdlist.append(template.gettext(strings.EMAIL_MISC_TEXT.get(3)))
+    for cmd, desc in strings.EMAIL_COMMANDS.items():
+        command  = '  '
+        command += cmd
+        while not len(command) >= 25:  # Align the command descriptions
+            command += ' '
+        command += template.gettext(desc)
+        cmdlist.append(command)
+
+    commands  = "\n".join(cmdlist) + "\n\n"
+    # And include the currently supported transports:
+    commands += template.gettext(strings.EMAIL_MISC_TEXT.get(5))
+    commands += "\n"
+    for pt in strings.CURRENT_TRANSPORTS:
+        commands += '  ' + pt + "\n"
+
+    return commands
+
+def buildHowto(template):
+    howToTBB  = template.gettext(strings.HOWTO_TBB[1]) % strings.EMAIL_SPRINTF["HOWTO_TBB1"]
+    howToTBB += u'\n\n'
+    howToTBB += template.gettext(strings.HOWTO_TBB[2])
+    howToTBB += u'\n\n'
+    howToTBB += u'\n'.join(["> {0}".format(ln) for ln in
+                            template.gettext(strings.HOWTO_TBB[3]).split('\n')])
+    howToTBB += u'\n\n'
+    howToTBB += template.gettext(strings.HOWTO_TBB[4])
+    howToTBB += u'\n\n'
+    howToTBB += strings.EMAIL_REFERENCE_LINKS.get("HOWTO_TBB1")
+    howToTBB += u'\n\n'
+    return howToTBB
+
+def buildKeyfile(template):
+    filename = os.path.join(TEMPLATE_DIR, 'bridgedb.asc')
+
+    try:
+        with open(filename) as fh:
+            keyFile  = fh.read()
+    except Exception as error:  # pragma: no cover
+        logging.exception(error)
+        keyFile = u''
+    else:
+        keyFile += u'\n\n'
+
+    return keyFile
+
+def buildWelcomeText(template):
+    sections = []
+    sections.append(template.gettext(strings.EMAIL_MISC_TEXT[4]))
+
+    commands = buildCommands(template)
+    sections.append(commands)
+
+    # Include the same messages as the homepage of the HTTPS distributor:
+    welcome  = template.gettext(strings.WELCOME[0]) % strings.EMAIL_SPRINTF["WELCOME0"]
+    welcome += template.gettext(strings.WELCOME[1])
+    welcome += template.gettext(strings.WELCOME[2]) % strings.EMAIL_SPRINTF["WELCOME2"]
+    sections.append(welcome)
+
+    message  = u"\n\n".join(sections)
+    # Add the markdown links at the end:
+    message += strings.EMAIL_REFERENCE_LINKS.get("WELCOME0")
+    message += u"\n"
+
+    return message
+
+def buildBridgeAnswer(template):
+    # Give the user their bridges, i.e. the `answer`:
+    message = template.gettext(strings.EMAIL_MISC_TEXT[0]) + u"\n\n" \
+              + template.gettext(strings.EMAIL_MISC_TEXT[1]) + u"\n\n" \
+              + u"%s\n\n"
+    return message
+
+def buildMessage(template):
+    message = None
+    try:
+        message  = buildBridgeAnswer(template)
+        message += buildHowto(template)
+        message += u'\n\n'
+        message += buildCommands(template)
+    except Exception as error:  # pragma: no cover
+        logging.error("Error while formatting email message template:")
+        logging.exception(error)
+    return message
+
+def buildSpamWarning(template):
+    message = None
+    try:
+        message = template.gettext(strings.EMAIL_MISC_TEXT[0]) + u"\n\n" \
+                  + template.gettext(strings.EMAIL_MISC_TEXT[2]) + u"\n"
+        message = message % str(MAX_EMAIL_RATE / 3600)
+    except Exception as error:  # pragma: no cover
+        logging.error("Error while formatting email spam template:")
+        logging.exception(error)
+    return message
diff --git a/setup.py b/setup.py
index 1b8c259..e21dbef 100644
--- a/setup.py
+++ b/setup.py
@@ -278,6 +278,7 @@ setuptools.setup(
     download_url='https://gitweb.torproject.org/bridgedb.git',
     package_dir={'': 'lib'},
     packages=['bridgedb',
+              'bridgedb.email',
               'bridgedb.parse',
               'bridgedb.test'],
     scripts=['scripts/bridgedb'],





More information about the tor-commits mailing list