commit 0a0af4b89f37bbaae40bb34654fdf854858e837a Author: Isis Lovecruft isis@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@torproject.org +# Isis Lovecruft isis@torproject.org 0xA3ADB67A2CDB8B35 +# Matthew Finkel sysrqb@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@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@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):
tor-commits@lists.torproject.org