[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]
[tor-commits] [bridgedb/develop] Completely rewrite email servers.
commit b11d1c513af7431f473d4443d37e40e1a407aee2
Author: Isis Lovecruft <isis@xxxxxxxxxxxxxx>
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@xxxxxxxxxxxxxxx
---
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@xxxxxxxxxxxxxx")
- # Use this address in the "From:" header for outgoing mail.
- self.fromAddr = (cfg.EMAIL_FROM_ADDR or
- "bridges@xxxxxxxxxxxxxx")
- # 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@xxxxxxxxxxxxxx>
+# Isis Lovecruft <isis@xxxxxxxxxxxxxx> 0xA3ADB67A2CDB8B35
+# Matthew Finkel <sysrqb@xxxxxxxxxxxxxx>
+# 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@xxxxxxxxxxxxxx``, 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@xxxxxxxxxxxxxx``)
+ :ivar str fromAddr: Use this address in the email :header:`From:`
+ line for outgoing mail. (default: ``bridges@xxxxxxxxxxxxxx``)
+ :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@xxxxxxxxxxxxxx")
+ 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@xxxxxxxxxxxxxx> 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'],
_______________________________________________
tor-commits mailing list
tor-commits@xxxxxxxxxxxxxxxxxxxx
https://lists.torproject.org/cgi-bin/mailman/listinfo/tor-commits