[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