diff options
author | shmel1k <shmel1k@ydb.tech> | 2023-11-26 18:16:14 +0300 |
---|---|---|
committer | shmel1k <shmel1k@ydb.tech> | 2023-11-26 18:43:30 +0300 |
commit | b8cf9e88f4c5c64d9406af533d8948deb050d695 (patch) | |
tree | 218eb61fb3c3b96ec08b4d8cdfef383104a87d63 /contrib/python/Twisted/py2/twisted/mail/smtp.py | |
parent | 523f645a83a0ec97a0332dbc3863bb354c92a328 (diff) | |
download | ydb-b8cf9e88f4c5c64d9406af533d8948deb050d695.tar.gz |
add kikimr_configure
Diffstat (limited to 'contrib/python/Twisted/py2/twisted/mail/smtp.py')
-rw-r--r-- | contrib/python/Twisted/py2/twisted/mail/smtp.py | 2247 |
1 files changed, 2247 insertions, 0 deletions
diff --git a/contrib/python/Twisted/py2/twisted/mail/smtp.py b/contrib/python/Twisted/py2/twisted/mail/smtp.py new file mode 100644 index 0000000000..7725ce5886 --- /dev/null +++ b/contrib/python/Twisted/py2/twisted/mail/smtp.py @@ -0,0 +1,2247 @@ +# -*- test-case-name: twisted.mail.test.test_smtp -*- +# Copyright (c) Twisted Matrix Laboratories. +# See LICENSE for details. +# +# pylint: disable=I0011,C0103,C9302 + +""" +Simple Mail Transfer Protocol implementation. +""" + +from __future__ import absolute_import, division + +import time +import re +import base64 +import socket +import os +import random +import binascii +import warnings + +from email.utils import parseaddr + +from zope.interface import implementer + +from twisted import cred +from twisted.copyright import longversion +from twisted.protocols import basic +from twisted.protocols import policies +from twisted.internet import protocol +from twisted.internet import defer +from twisted.internet import error +from twisted.internet import reactor +from twisted.internet.interfaces import ITLSTransport, ISSLTransport +from twisted.python import log +from twisted.python import util +from twisted.python.compat import (_PY3, range, long, unicode, networkString, + nativeString, iteritems, _keys, _bytesChr, + iterbytes) +from twisted.python.runtime import platform + +from twisted.mail.interfaces import (IClientAuthentication, + IMessageSMTP as IMessage, + IMessageDeliveryFactory, IMessageDelivery) +from twisted.mail._cred import (CramMD5ClientAuthenticator, LOGINAuthenticator, + LOGINCredentials as _lcredentials) +from twisted.mail._except import ( + AUTHDeclinedError, AUTHRequiredError, AddressError, + AuthenticationError, EHLORequiredError, ESMTPClientError, + SMTPAddressError, SMTPBadRcpt, SMTPBadSender, SMTPClientError, + SMTPConnectError, SMTPDeliveryError, SMTPError, SMTPServerError, + SMTPTimeoutError, SMTPTLSError as TLSError, TLSRequiredError, + SMTPProtocolError) + + +from io import BytesIO + + +__all__ = [ + 'AUTHDeclinedError', 'AUTHRequiredError', 'AddressError', + 'AuthenticationError', 'EHLORequiredError', 'ESMTPClientError', + 'SMTPAddressError', 'SMTPBadRcpt', 'SMTPBadSender', 'SMTPClientError', + 'SMTPConnectError', 'SMTPDeliveryError', 'SMTPError', 'SMTPServerError', + 'SMTPTimeoutError', 'TLSError', 'TLSRequiredError', 'SMTPProtocolError', + + 'IClientAuthentication', 'IMessage', 'IMessageDelivery', + 'IMessageDeliveryFactory', + + 'CramMD5ClientAuthenticator', 'LOGINAuthenticator', 'LOGINCredentials', + 'PLAINAuthenticator', + + 'Address', 'User', 'sendmail', 'SenderMixin', + 'ESMTP', 'ESMTPClient', 'ESMTPSender', 'ESMTPSenderFactory', + 'SMTP', 'SMTPClient', 'SMTPFactory', 'SMTPSender', 'SMTPSenderFactory', + + 'idGenerator', 'messageid', 'quoteaddr', 'rfc822date', 'xtextStreamReader', + 'xtextStreamWriter', 'xtext_codec', 'xtext_decode', 'xtext_encode' +] + + +# Cache the hostname (XXX Yes - this is broken) +if platform.isMacOSX(): + # On macOS, getfqdn() is ridiculously slow - use the + # probably-identical-but-sometimes-not gethostname() there. + DNSNAME = socket.gethostname() +else: + DNSNAME = socket.getfqdn() + +# Encode the DNS name into something we can send over the wire +DNSNAME = DNSNAME.encode('ascii') + +# Used for fast success code lookup +SUCCESS = dict.fromkeys(range(200, 300)) + + + +def rfc822date(timeinfo=None, local=1): + """ + Format an RFC-2822 compliant date string. + + @param timeinfo: (optional) A sequence as returned by C{time.localtime()} + or C{time.gmtime()}. Default is now. + @param local: (optional) Indicates if the supplied time is local or + universal time, or if no time is given, whether now should be local or + universal time. Default is local, as suggested (SHOULD) by rfc-2822. + + @returns: A L{bytes} representing the time and date in RFC-2822 format. + """ + if not timeinfo: + if local: + timeinfo = time.localtime() + else: + timeinfo = time.gmtime() + if local: + if timeinfo[8]: + # DST + tz = -time.altzone + else: + tz = -time.timezone + + (tzhr, tzmin) = divmod(abs(tz), 3600) + if tz: + tzhr *= int(abs(tz)//tz) + (tzmin, tzsec) = divmod(tzmin, 60) + else: + (tzhr, tzmin) = (0, 0) + + return networkString("%s, %02d %s %04d %02d:%02d:%02d %+03d%02d" % ( + ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][timeinfo[6]], + timeinfo[2], + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][timeinfo[1] - 1], + timeinfo[0], timeinfo[3], timeinfo[4], timeinfo[5], + tzhr, tzmin)) + + + +def idGenerator(): + i = 0 + while True: + yield i + i += 1 + +_gen = idGenerator() + + + +def messageid(uniq=None, N=lambda: next(_gen)): + """ + Return a globally unique random string in RFC 2822 Message-ID format + + <datetime.pid.random@host.dom.ain> + + Optional uniq string will be added to strengthen uniqueness if given. + """ + datetime = time.strftime('%Y%m%d%H%M%S', time.gmtime()) + pid = os.getpid() + rand = random.randrange(2**31-1) + if uniq is None: + uniq = '' + else: + uniq = '.' + uniq + + return '<%s.%s.%s%s.%s@%s>' % (datetime, pid, rand, uniq, N(), DNSNAME) + + + +def quoteaddr(addr): + """ + Turn an email address, possibly with realname part etc, into + a form suitable for and SMTP envelope. + """ + + if isinstance(addr, Address): + return b'<' + bytes(addr) + b'>' + + if isinstance(addr, bytes): + addr = addr.decode('ascii') + + res = parseaddr(addr) + + if res == (None, None): + # It didn't parse, use it as-is + return b'<' + bytes(addr) + b'>' + else: + return b'<' + res[1].encode('ascii') + b'>' + +COMMAND, DATA, AUTH = 'COMMAND', 'DATA', 'AUTH' + + +# Character classes for parsing addresses +atom = br"[-A-Za-z0-9!\#$%&'*+/=?^_`{|}~]" + +class Address: + """Parse and hold an RFC 2821 address. + + Source routes are stipped and ignored, UUCP-style bang-paths + and %-style routing are not parsed. + + @type domain: C{bytes} + @ivar domain: The domain within which this address resides. + + @type local: C{bytes} + @ivar local: The local (\"user\") portion of this address. + """ + + tstring = re.compile(br'''( # A string of + (?:"[^"]*" # quoted string + |\\. # backslash-escaped characted + |''' + atom + br''' # atom character + )+|.) # or any single character''', re.X) + atomre = re.compile(atom) # match any one atom character + + + def __init__(self, addr, defaultDomain=None): + if isinstance(addr, User): + addr = addr.dest + if isinstance(addr, Address): + self.__dict__ = addr.__dict__.copy() + return + elif not isinstance(addr, bytes): + addr = str(addr).encode('ascii') + + self.addrstr = addr + + # Tokenize + atl = list(filter(None, self.tstring.split(addr))) + local = [] + domain = [] + + while atl: + if atl[0] == b'<': + if atl[-1] != b'>': + raise AddressError("Unbalanced <>") + atl = atl[1:-1] + elif atl[0] == b'@': + atl = atl[1:] + if not local: + # Source route + while atl and atl[0] != b':': + # remove it + atl = atl[1:] + if not atl: + raise AddressError("Malformed source route") + atl = atl[1:] # remove : + elif domain: + raise AddressError("Too many @") + else: + # Now in domain + domain = [b''] + elif (len(atl[0]) == 1 and + not self.atomre.match(atl[0]) and + atl[0] != b'.'): + raise AddressError("Parse error at %r of %r" % (atl[0], (addr, atl))) + else: + if not domain: + local.append(atl[0]) + else: + domain.append(atl[0]) + atl = atl[1:] + + self.local = b''.join(local) + self.domain = b''.join(domain) + if self.local != b'' and self.domain == b'': + if defaultDomain is None: + defaultDomain = DNSNAME + self.domain = defaultDomain + + dequotebs = re.compile(br'\\(.)') + + + def dequote(self, addr): + """ + Remove RFC-2821 quotes from address. + """ + res = [] + + if not isinstance(addr, bytes): + addr = str(addr).encode('ascii') + + atl = filter(None, self.tstring.split(addr)) + + for t in atl: + if t[0] == b'"' and t[-1] == b'"': + res.append(t[1:-1]) + elif '\\' in t: + res.append(self.dequotebs.sub(br'\1', t)) + else: + res.append(t) + + return b''.join(res) + + if _PY3: + def __str__(self): + return nativeString(bytes(self)) + else: + def __str__(self): + return self.__bytes__() + + + def __bytes__(self): + if self.local or self.domain: + return b'@'.join((self.local, self.domain)) + else: + return b'' + + + def __repr__(self): + return "%s.%s(%s)" % (self.__module__, self.__class__.__name__, + repr(str(self))) + + + +class User: + """ + Hold information about and SMTP message recipient, + including information on where the message came from + """ + def __init__(self, destination, helo, protocol, orig): + try: + host = protocol.host + except AttributeError: + host = None + self.dest = Address(destination, host) + self.helo = helo + self.protocol = protocol + if isinstance(orig, Address): + self.orig = orig + else: + self.orig = Address(orig, host) + + + def __getstate__(self): + """ + Helper for pickle. + + protocol isn't picklabe, but we want User to be, so skip it in + the pickle. + """ + return { 'dest' : self.dest, + 'helo' : self.helo, + 'protocol' : None, + 'orig' : self.orig } + + + def __str__(self): + return nativeString(bytes(self.dest)) + + + def __bytes__(self): + return bytes(self.dest) + + + +class SMTP(basic.LineOnlyReceiver, policies.TimeoutMixin): + """ + SMTP server-side protocol. + + @ivar host: The hostname of this mail server. + @type host: L{bytes} + """ + + timeout = 600 + portal = None + + # Control whether we log SMTP events + noisy = True + + # A factory for IMessageDelivery objects. If an + # avatar implementing IMessageDeliveryFactory can + # be acquired from the portal, it will be used to + # create a new IMessageDelivery object for each + # message which is received. + deliveryFactory = None + + # An IMessageDelivery object. A new instance is + # used for each message received if we can get an + # IMessageDeliveryFactory from the portal. Otherwise, + # a single instance is used throughout the lifetime + # of the connection. + delivery = None + + # Cred cleanup function. + _onLogout = None + + def __init__(self, delivery=None, deliveryFactory=None): + self.mode = COMMAND + self._from = None + self._helo = None + self._to = [] + self.delivery = delivery + self.deliveryFactory = deliveryFactory + self.host = DNSNAME + + + @property + def host(self): + return self._host + + + @host.setter + def host(self, toSet): + if not isinstance(toSet, bytes): + toSet = str(toSet).encode('ascii') + self._host = toSet + + + + def timeoutConnection(self): + msg = self.host + b' Timeout. Try talking faster next time!' + self.sendCode(421, msg) + self.transport.loseConnection() + + + def greeting(self): + return self.host + b' NO UCE NO UBE NO RELAY PROBES' + + + def connectionMade(self): + # Ensure user-code always gets something sane for _helo + peer = self.transport.getPeer() + try: + host = peer.host + except AttributeError: # not an IPv4Address + host = str(peer) + self._helo = (None, host) + self.sendCode(220, self.greeting()) + self.setTimeout(self.timeout) + + + def sendCode(self, code, message=b''): + """ + Send an SMTP code with a message. + """ + lines = message.splitlines() + lastline = lines[-1:] + for line in lines[:-1]: + self.sendLine(networkString('%3.3d-' % (code,)) + line) + self.sendLine(networkString('%3.3d ' % (code,)) + + (lastline and lastline[0] or b'')) + + + def lineReceived(self, line): + self.resetTimeout() + return getattr(self, 'state_' + self.mode)(line) + + + def state_COMMAND(self, line): + # Ignore leading and trailing whitespace, as well as an arbitrary + # amount of whitespace between the command and its argument, though + # it is not required by the protocol, for it is a nice thing to do. + line = line.strip() + + parts = line.split(None, 1) + if parts: + method = self.lookupMethod(parts[0]) or self.do_UNKNOWN + if len(parts) == 2: + method(parts[1]) + else: + method(b'') + else: + self.sendSyntaxError() + + + def sendSyntaxError(self): + self.sendCode(500, b'Error: bad syntax') + + + def lookupMethod(self, command): + """ + + @param command: The command to get from this class. + @type command: L{str} + @return: The function which executes this command. + """ + if not isinstance(command, str): + command = nativeString(command) + + return getattr(self, 'do_' + command.upper(), None) + + + def lineLengthExceeded(self, line): + if self.mode is DATA: + for message in self.__messages: + message.connectionLost() + self.mode = COMMAND + del self.__messages + self.sendCode(500, b'Line too long') + + + def do_UNKNOWN(self, rest): + self.sendCode(500, b'Command not implemented') + + + def do_HELO(self, rest): + peer = self.transport.getPeer() + try: + host = peer.host + except AttributeError: + host = str(peer) + + if not isinstance(host, bytes): + host = host.encode('idna') + + self._helo = (rest, host) + self._from = None + self._to = [] + self.sendCode(250, + self.host + b' Hello ' + host + b', nice to meet you') + + + def do_QUIT(self, rest): + self.sendCode(221, b'See you later') + self.transport.loseConnection() + + # A string of quoted strings, backslash-escaped character or + # atom characters + '@.,:' + qstring = br'("[^"]*"|\\.|' + atom + br'|[@.,:])+' + + mail_re = re.compile(br'''\s*FROM:\s*(?P<path><> # Empty <> + |<''' + qstring + br'''> # <addr> + |''' + qstring + br''' # addr + )\s*(\s(?P<opts>.*))? # Optional WS + ESMTP options + $''', re.I|re.X) + rcpt_re = re.compile(br'\s*TO:\s*(?P<path><' + qstring + br'''> # <addr> + |''' + qstring + br''' # addr + )\s*(\s(?P<opts>.*))? # Optional WS + ESMTP options + $''', re.I|re.X) + + def do_MAIL(self, rest): + if self._from: + self.sendCode(503, b"Only one sender per message, please") + return + # Clear old recipient list + self._to = [] + m = self.mail_re.match(rest) + if not m: + self.sendCode(501, b"Syntax error") + return + + try: + addr = Address(m.group('path'), self.host) + except AddressError as e: + self.sendCode(553, networkString(str(e))) + return + + validated = defer.maybeDeferred(self.validateFrom, self._helo, addr) + validated.addCallbacks(self._cbFromValidate, self._ebFromValidate) + + + def _cbFromValidate(self, fromEmail, code=250, + msg=b'Sender address accepted'): + self._from = fromEmail + self.sendCode(code, msg) + + + def _ebFromValidate(self, failure): + if failure.check(SMTPBadSender): + self.sendCode(failure.value.code, + (b'Cannot receive from specified address ' + + quoteaddr(failure.value.addr) + b': ' + + networkString(failure.value.resp))) + elif failure.check(SMTPServerError): + self.sendCode(failure.value.code, + networkString(failure.value.resp)) + else: + log.err(failure, "SMTP sender validation failure") + self.sendCode( + 451, + b'Requested action aborted: local error in processing') + + + def do_RCPT(self, rest): + if not self._from: + self.sendCode(503, b"Must have sender before recipient") + return + m = self.rcpt_re.match(rest) + if not m: + self.sendCode(501, b"Syntax error") + return + + try: + user = User(m.group('path'), self._helo, self, self._from) + except AddressError as e: + self.sendCode(553, networkString(str(e))) + return + + d = defer.maybeDeferred(self.validateTo, user) + d.addCallbacks( + self._cbToValidate, + self._ebToValidate, + callbackArgs=(user,) + ) + + + def _cbToValidate(self, to, user=None, code=250, + msg=b'Recipient address accepted'): + if user is None: + user = to + self._to.append((user, to)) + self.sendCode(code, msg) + + + def _ebToValidate(self, failure): + if failure.check(SMTPBadRcpt, SMTPServerError): + self.sendCode(failure.value.code, + networkString(failure.value.resp)) + else: + log.err(failure) + self.sendCode( + 451, + b'Requested action aborted: local error in processing' + ) + + + def _disconnect(self, msgs): + for msg in msgs: + try: + msg.connectionLost() + except: + log.msg("msg raised exception from connectionLost") + log.err() + + + def do_DATA(self, rest): + if self._from is None or (not self._to): + self.sendCode(503, b'Must have valid receiver and originator') + return + self.mode = DATA + helo, origin = self._helo, self._from + recipients = self._to + + self._from = None + self._to = [] + self.datafailed = None + + msgs = [] + for (user, msgFunc) in recipients: + try: + msg = msgFunc() + rcvdhdr = self.receivedHeader(helo, origin, [user]) + if rcvdhdr: + msg.lineReceived(rcvdhdr) + msgs.append(msg) + except SMTPServerError as e: + self.sendCode(e.code, e.resp) + self.mode = COMMAND + self._disconnect(msgs) + return + except: + log.err() + self.sendCode(550, b"Internal server error") + self.mode = COMMAND + self._disconnect(msgs) + return + self.__messages = msgs + + self.__inheader = self.__inbody = 0 + self.sendCode(354, b'Continue') + + if self.noisy: + fmt = 'Receiving message for delivery: from=%s to=%s' + log.msg(fmt % (origin, [str(u) for (u, f) in recipients])) + + + def connectionLost(self, reason): + # self.sendCode(421, 'Dropping connection.') # This does nothing... + # Ideally, if we (rather than the other side) lose the connection, + # we should be able to tell the other side that we are going away. + # RFC-2821 requires that we try. + if self.mode is DATA: + try: + for message in self.__messages: + try: + message.connectionLost() + except: + log.err() + del self.__messages + except AttributeError: + pass + if self._onLogout: + self._onLogout() + self._onLogout = None + self.setTimeout(None) + + + def do_RSET(self, rest): + self._from = None + self._to = [] + self.sendCode(250, b'I remember nothing.') + + + def dataLineReceived(self, line): + if line[:1] == b'.': + if line == b'.': + self.mode = COMMAND + if self.datafailed: + self.sendCode(self.datafailed.code, + self.datafailed.resp) + return + if not self.__messages: + self._messageHandled("thrown away") + return + defer.DeferredList([ + m.eomReceived() for m in self.__messages + ], consumeErrors=True).addCallback(self._messageHandled + ) + del self.__messages + return + line = line[1:] + + if self.datafailed: + return + + try: + # Add a blank line between the generated Received:-header + # and the message body if the message comes in without any + # headers + if not self.__inheader and not self.__inbody: + if b':' in line: + self.__inheader = 1 + elif line: + for message in self.__messages: + message.lineReceived(b'') + self.__inbody = 1 + + if not line: + self.__inbody = 1 + + for message in self.__messages: + message.lineReceived(line) + except SMTPServerError as e: + self.datafailed = e + for message in self.__messages: + message.connectionLost() + state_DATA = dataLineReceived + + + def _messageHandled(self, resultList): + failures = 0 + for (success, result) in resultList: + if not success: + failures += 1 + log.err(result) + if failures: + msg = 'Could not send e-mail' + resultLen = len(resultList) + if resultLen > 1: + msg += ' (%d failures out of %d recipients)'.format( + failures, resultLen) + self.sendCode(550, networkString(msg)) + else: + self.sendCode(250, b'Delivery in progress') + + + def _cbAnonymousAuthentication(self, result): + """ + Save the state resulting from a successful anonymous cred login. + """ + (iface, avatar, logout) = result + if issubclass(iface, IMessageDeliveryFactory): + self.deliveryFactory = avatar + self.delivery = None + elif issubclass(iface, IMessageDelivery): + self.deliveryFactory = None + self.delivery = avatar + else: + raise RuntimeError("%s is not a supported interface" % (iface.__name__,)) + self._onLogout = logout + self.challenger = None + + + # overridable methods: + def validateFrom(self, helo, origin): + """ + Validate the address from which the message originates. + + @type helo: C{(bytes, bytes)} + @param helo: The argument to the HELO command and the client's IP + address. + + @type origin: C{Address} + @param origin: The address the message is from + + @rtype: C{Deferred} or C{Address} + @return: C{origin} or a C{Deferred} whose callback will be + passed C{origin}. + + @raise SMTPBadSender: Raised of messages from this address are + not to be accepted. + """ + if self.deliveryFactory is not None: + self.delivery = self.deliveryFactory.getMessageDelivery() + + if self.delivery is not None: + return defer.maybeDeferred(self.delivery.validateFrom, + helo, origin) + + # No login has been performed, no default delivery object has been + # provided: try to perform an anonymous login and then invoke this + # method again. + if self.portal: + + result = self.portal.login( + cred.credentials.Anonymous(), + None, + IMessageDeliveryFactory, IMessageDelivery) + + def ebAuthentication(err): + """ + Translate cred exceptions into SMTP exceptions so that the + protocol code which invokes C{validateFrom} can properly report + the failure. + """ + if err.check(cred.error.UnauthorizedLogin): + exc = SMTPBadSender(origin) + elif err.check(cred.error.UnhandledCredentials): + exc = SMTPBadSender( + origin, resp="Unauthenticated senders not allowed") + else: + return err + return defer.fail(exc) + + result.addCallbacks( + self._cbAnonymousAuthentication, ebAuthentication) + + def continueValidation(ignored): + """ + Re-attempt from address validation. + """ + return self.validateFrom(helo, origin) + + result.addCallback(continueValidation) + return result + + raise SMTPBadSender(origin) + + + def validateTo(self, user): + """ + Validate the address for which the message is destined. + + @type user: L{User} + @param user: The address to validate. + + @rtype: no-argument callable + @return: A C{Deferred} which becomes, or a callable which + takes no arguments and returns an object implementing C{IMessage}. + This will be called and the returned object used to deliver the + message when it arrives. + + @raise SMTPBadRcpt: Raised if messages to the address are + not to be accepted. + """ + if self.delivery is not None: + return self.delivery.validateTo(user) + raise SMTPBadRcpt(user) + + + def receivedHeader(self, helo, origin, recipients): + if self.delivery is not None: + return self.delivery.receivedHeader(helo, origin, recipients) + + heloStr = b"" + if helo[0]: + heloStr = b" helo=" + helo[0] + domain = networkString(self.transport.getHost().host) + + from_ = b"from " + helo[0] + b" ([" + helo[1] + b"]" + heloStr + b")" + by = b"by %s with %s (%s)" % (domain, + self.__class__.__name__, + longversion) + for_ = b"for %s; %s" % (' '.join(map(str, recipients)), + rfc822date()) + return b"Received: " + from_ + b"\n\t" + by + b"\n\t" + for_ + + + +class SMTPFactory(protocol.ServerFactory): + """ + Factory for SMTP. + """ + + # override in instances or subclasses + domain = DNSNAME + timeout = 600 + protocol = SMTP + + portal = None + + def __init__(self, portal = None): + self.portal = portal + + + def buildProtocol(self, addr): + p = protocol.ServerFactory.buildProtocol(self, addr) + p.portal = self.portal + p.host = self.domain + return p + + + +class SMTPClient(basic.LineReceiver, policies.TimeoutMixin): + """ + SMTP client for sending emails. + + After the client has connected to the SMTP server, it repeatedly calls + L{SMTPClient.getMailFrom}, L{SMTPClient.getMailTo} and + L{SMTPClient.getMailData} and uses this information to send an email. + It then calls L{SMTPClient.getMailFrom} again; if it returns L{None}, the + client will disconnect, otherwise it will continue as normal i.e. call + L{SMTPClient.getMailTo} and L{SMTPClient.getMailData} and send a new email. + """ + + # If enabled then log SMTP client server communication + debug = True + + # Number of seconds to wait before timing out a connection. If + # None, perform no timeout checking. + timeout = None + + def __init__(self, identity, logsize=10): + if isinstance(identity, unicode): + identity = identity.encode('ascii') + + self.identity = identity or b'' + self.toAddressesResult = [] + self.successAddresses = [] + self._from = None + self.resp = [] + self.code = -1 + self.log = util.LineLog(logsize) + + + def sendLine(self, line): + # Log sendLine only if you are in debug mode for performance + if self.debug: + self.log.append(b'>>> ' + line) + + basic.LineReceiver.sendLine(self,line) + + + def connectionMade(self): + self.setTimeout(self.timeout) + + self._expected = [ 220 ] + self._okresponse = self.smtpState_helo + self._failresponse = self.smtpConnectionFailed + + + def connectionLost(self, reason=protocol.connectionDone): + """ + We are no longer connected + """ + self.setTimeout(None) + self.mailFile = None + + + def timeoutConnection(self): + self.sendError( + SMTPTimeoutError( + -1, b"Timeout waiting for SMTP server response", + self.log.str())) + + + def lineReceived(self, line): + self.resetTimeout() + + # Log lineReceived only if you are in debug mode for performance + if self.debug: + self.log.append(b'<<< ' + line) + + why = None + + try: + self.code = int(line[:3]) + except ValueError: + # This is a fatal error and will disconnect the transport + # lineReceived will not be called again. + self.sendError(SMTPProtocolError(-1, + "Invalid response from SMTP server: {}".format(line), + self.log.str())) + return + + if line[0:1] == b'0': + # Verbose informational message, ignore it + return + + self.resp.append(line[4:]) + + if line[3:4] == b'-': + # Continuation + return + + if self.code in self._expected: + why = self._okresponse(self.code, b'\n'.join(self.resp)) + else: + why = self._failresponse(self.code, b'\n'.join(self.resp)) + + self.code = -1 + self.resp = [] + return why + + + def smtpConnectionFailed(self, code, resp): + self.sendError(SMTPConnectError(code, resp, self.log.str())) + + + def smtpTransferFailed(self, code, resp): + if code < 0: + self.sendError(SMTPProtocolError(code, resp, self.log.str())) + else: + self.smtpState_msgSent(code, resp) + + + def smtpState_helo(self, code, resp): + self.sendLine(b'HELO ' + self.identity) + self._expected = SUCCESS + self._okresponse = self.smtpState_from + + + def smtpState_from(self, code, resp): + self._from = self.getMailFrom() + self._failresponse = self.smtpTransferFailed + if self._from is not None: + self.sendLine(b'MAIL FROM:' + quoteaddr(self._from)) + self._expected = [250] + self._okresponse = self.smtpState_to + else: + # All messages have been sent, disconnect + self._disconnectFromServer() + + + def smtpState_disconnect(self, code, resp): + self.transport.loseConnection() + + + def smtpState_to(self, code, resp): + self.toAddresses = iter(self.getMailTo()) + self.toAddressesResult = [] + self.successAddresses = [] + self._okresponse = self.smtpState_toOrData + self._expected = range(0, 1000) + self.lastAddress = None + return self.smtpState_toOrData(0, b'') + + + def smtpState_toOrData(self, code, resp): + if self.lastAddress is not None: + self.toAddressesResult.append((self.lastAddress, code, resp)) + if code in SUCCESS: + self.successAddresses.append(self.lastAddress) + try: + self.lastAddress = next(self.toAddresses) + except StopIteration: + if self.successAddresses: + self.sendLine(b'DATA') + self._expected = [ 354 ] + self._okresponse = self.smtpState_data + else: + return self.smtpState_msgSent(code,'No recipients accepted') + else: + self.sendLine(b'RCPT TO:' + quoteaddr(self.lastAddress)) + + + def smtpState_data(self, code, resp): + s = basic.FileSender() + d = s.beginFileTransfer( + self.getMailData(), self.transport, self.transformChunk) + def ebTransfer(err): + self.sendError(err.value) + d.addCallbacks(self.finishedFileTransfer, ebTransfer) + self._expected = SUCCESS + self._okresponse = self.smtpState_msgSent + + + def smtpState_msgSent(self, code, resp): + if self._from is not None: + self.sentMail(code, resp, len(self.successAddresses), + self.toAddressesResult, self.log) + + self.toAddressesResult = [] + self._from = None + self.sendLine(b'RSET') + self._expected = SUCCESS + self._okresponse = self.smtpState_from + + + ## + ## Helpers for FileSender + ## + def transformChunk(self, chunk): + """ + Perform the necessary local to network newline conversion and escape + leading periods. + + This method also resets the idle timeout so that as long as process is + being made sending the message body, the client will not time out. + """ + self.resetTimeout() + return chunk.replace(b'\n', b'\r\n').replace(b'\r\n.', b'\r\n..') + + + def finishedFileTransfer(self, lastsent): + if lastsent != b'\n': + line = b'\r\n.' + else: + line = b'.' + self.sendLine(line) + + + ## + # these methods should be overridden in subclasses + def getMailFrom(self): + """ + Return the email address the mail is from. + """ + raise NotImplementedError + + + def getMailTo(self): + """ + Return a list of emails to send to. + """ + raise NotImplementedError + + + def getMailData(self): + """ + Return file-like object containing data of message to be sent. + + Lines in the file should be delimited by '\\n'. + """ + raise NotImplementedError + + + def sendError(self, exc): + """ + If an error occurs before a mail message is sent sendError will be + called. This base class method sends a QUIT if the error is + non-fatal and disconnects the connection. + + @param exc: The SMTPClientError (or child class) raised + @type exc: C{SMTPClientError} + """ + if isinstance(exc, SMTPClientError) and not exc.isFatal: + self._disconnectFromServer() + else: + # If the error was fatal then the communication channel with the + # SMTP Server is broken so just close the transport connection + self.smtpState_disconnect(-1, None) + + + def sentMail(self, code, resp, numOk, addresses, log): + """ + 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 code: the code returned by the SMTP Server + @param resp: The string response returned from the SMTP Server + @param numOK: the number of addresses accepted by the remote host. + @param addresses: is a list of tuples (address, code, resp) listing + the response to each RCPT command. + @param log: is the SMTP session log + """ + raise NotImplementedError + + + def _disconnectFromServer(self): + self._expected = range(0, 1000) + self._okresponse = self.smtpState_disconnect + self.sendLine(b'QUIT') + + + +class ESMTPClient(SMTPClient): + """ + A client for sending emails over ESMTP. + + @ivar heloFallback: Whether or not to fall back to plain SMTP if the C{EHLO} + command is not recognised by the server. If L{requireAuthentication} is + C{True}, or L{requireTransportSecurity} is C{True} and the connection is + not over TLS, this fallback flag will not be honored. + @type heloFallback: L{bool} + + @ivar requireAuthentication: If C{True}, refuse to proceed if authentication + cannot be performed. Overrides L{heloFallback}. + @type requireAuthentication: L{bool} + + @ivar requireTransportSecurity: If C{True}, refuse to proceed if the + transport cannot be secured. If the transport layer is not already + secured via TLS, this will override L{heloFallback}. + @type requireAuthentication: L{bool} + + @ivar context: The context factory to use for STARTTLS, if desired. + @type context: L{ssl.ClientContextFactory} + + @ivar _tlsMode: Whether or not the connection is over TLS. + @type _tlsMode: L{bool} + """ + heloFallback = True + requireAuthentication = False + requireTransportSecurity = False + context = None + _tlsMode = False + + def __init__(self, secret, contextFactory=None, *args, **kw): + SMTPClient.__init__(self, *args, **kw) + self.authenticators = [] + self.secret = secret + self.context = contextFactory + + + def __getattr__(self, name): + if name == "tlsMode": + warnings.warn( + "tlsMode attribute of twisted.mail.smtp.ESMTPClient " + "is deprecated since Twisted 13.0", + category=DeprecationWarning, stacklevel=2) + return self._tlsMode + else: + raise AttributeError( + '%s instance has no attribute %r' % ( + self.__class__.__name__, name,)) + + + def __setattr__(self, name, value): + if name == "tlsMode": + warnings.warn( + "tlsMode attribute of twisted.mail.smtp.ESMTPClient " + "is deprecated since Twisted 13.0", + category=DeprecationWarning, stacklevel=2) + self._tlsMode = value + else: + self.__dict__[name] = value + + + def esmtpEHLORequired(self, code=-1, resp=None): + """ + Fail because authentication is required, but the server does not support + ESMTP, which is required for authentication. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError(EHLORequiredError(502, b"Server does not support ESMTP " + b"Authentication", self.log.str())) + + + def esmtpAUTHRequired(self, code=-1, resp=None): + """ + Fail because authentication is required, but the server does not support + any schemes we support. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + tmp = [] + + for a in self.authenticators: + tmp.append(a.getName().upper()) + + auth = b"[%s]" % b", ".join(tmp) + + self.sendError(AUTHRequiredError(502, b"Server does not support Client " + b"Authentication schemes %s" % auth, self.log.str())) + + + def esmtpTLSRequired(self, code=-1, resp=None): + """ + Fail because TLS is required and the server does not support it. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError(TLSRequiredError(502, b"Server does not support secure " + b"communication via TLS / SSL", self.log.str())) + + + def esmtpTLSFailed(self, code=-1, resp=None): + """ + Fail because the TLS handshake wasn't able to be completed. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError(TLSError(code, b"Could not complete the SSL/TLS " + b"handshake", self.log.str())) + + + def esmtpAUTHDeclined(self, code=-1, resp=None): + """ + Fail because the authentication was rejected. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError(AUTHDeclinedError(code, resp, self.log.str())) + + + def esmtpAUTHMalformedChallenge(self, code=-1, resp=None): + """ + Fail because the server sent a malformed authentication challenge. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError(AuthenticationError(501, b"Login failed because the " + b"SMTP Server returned a malformed Authentication Challenge", + self.log.str())) + + + def esmtpAUTHServerError(self, code=-1, resp=None): + """ + Fail because of some other authentication error. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + """ + self.sendError(AuthenticationError(code, resp, self.log.str())) + + + def registerAuthenticator(self, auth): + """ + Registers an Authenticator with the ESMTPClient. The ESMTPClient will + attempt to login to the SMTP Server in the order the Authenticators are + registered. The most secure Authentication mechanism should be + registered first. + + @param auth: The Authentication mechanism to register + @type auth: L{IClientAuthentication} implementor + + @return: L{None} + """ + self.authenticators.append(auth) + + + def connectionMade(self): + """ + Called when a connection has been made, and triggers sending an C{EHLO} + to the server. + """ + self._tlsMode = ISSLTransport.providedBy(self.transport) + SMTPClient.connectionMade(self) + self._okresponse = self.esmtpState_ehlo + + + def esmtpState_ehlo(self, code, resp): + """ + Send an C{EHLO} to the server. + + If L{heloFallback} is C{True}, and there is no requirement for TLS or + authentication, the client will fall back to basic SMTP. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + + @return: L{None} + """ + self._expected = SUCCESS + + self._okresponse = self.esmtpState_serverConfig + self._failresponse = self.esmtpEHLORequired + + if self._tlsMode: + needTLS = False + else: + needTLS = self.requireTransportSecurity + + if self.heloFallback and not self.requireAuthentication and not needTLS: + self._failresponse = self.smtpState_helo + + self.sendLine(b"EHLO " + self.identity) + + + def esmtpState_serverConfig(self, code, resp): + """ + Handle a positive response to the I{EHLO} command by parsing the + capabilities in the server's response and then taking the most + appropriate next step towards entering a mail transaction. + """ + items = {} + for line in resp.splitlines(): + e = line.split(None, 1) + if len(e) > 1: + items[e[0]] = e[1] + else: + items[e[0]] = None + + self.tryTLS(code, resp, items) + + + def tryTLS(self, code, resp, items): + """ + Take a necessary step towards being able to begin a mail transaction. + + The step may be to ask the server to being a TLS session. If TLS is + already in use or not necessary and not available then the step may be + to authenticate with the server. If TLS is necessary and not available, + fail the mail transmission attempt. + + This is an internal helper method. + + @param code: The server status code from the most recently received + server message. + @type code: L{int} + + @param resp: The server status response from the most recently received + server message. + @type resp: L{bytes} + + @param items: A mapping of ESMTP extensions offered by the server. Keys + are extension identifiers and values are the associated values. + @type items: L{dict} mapping L{bytes} to L{bytes} + + @return: L{None} + """ + + # has tls can tls must tls result + # t t t authenticate + # t t f authenticate + # t f t authenticate + # t f f authenticate + + # f t t STARTTLS + # f t f STARTTLS + # f f t esmtpTLSRequired + # f f f authenticate + + hasTLS = self._tlsMode + canTLS = self.context and b"STARTTLS" in items + mustTLS = self.requireTransportSecurity + + if hasTLS or not (canTLS or mustTLS): + self.authenticate(code, resp, items) + elif canTLS: + self._expected = [220] + self._okresponse = self.esmtpState_starttls + self._failresponse = self.esmtpTLSFailed + self.sendLine(b"STARTTLS") + else: + self.esmtpTLSRequired() + + + def esmtpState_starttls(self, code, resp): + """ + Handle a positive response to the I{STARTTLS} command by starting a new + TLS session on C{self.transport}. + + Upon success, re-handshake with the server to discover what capabilities + it has when TLS is in use. + """ + try: + self.transport.startTLS(self.context) + self._tlsMode = True + except: + log.err() + self.esmtpTLSFailed(451) + + # Send another EHLO once TLS has been started to + # get the TLS / AUTH schemes. Some servers only allow AUTH in TLS mode. + self.esmtpState_ehlo(code, resp) + + + def authenticate(self, code, resp, items): + if self.secret and items.get(b'AUTH'): + schemes = items[b'AUTH'].split() + tmpSchemes = {} + + #XXX: May want to come up with a more efficient way to do this + for s in schemes: + tmpSchemes[s.upper()] = 1 + + for a in self.authenticators: + auth = a.getName().upper() + + if auth in tmpSchemes: + self._authinfo = a + + # Special condition handled + if auth == b"PLAIN": + self._okresponse = self.smtpState_from + self._failresponse = self._esmtpState_plainAuth + self._expected = [235] + challenge = base64.b64encode( + self._authinfo.challengeResponse(self.secret, 1)) + self.sendLine(b"AUTH %s %s" % (auth, challenge)) + else: + self._expected = [334] + self._okresponse = self.esmtpState_challenge + # If some error occurs here, the server declined the + # AUTH before the user / password phase. This would be + # a very rare case + self._failresponse = self.esmtpAUTHServerError + self.sendLine(b'AUTH ' + auth) + return + + if self.requireAuthentication: + self.esmtpAUTHRequired() + else: + self.smtpState_from(code, resp) + + + def _esmtpState_plainAuth(self, code, resp): + self._okresponse = self.smtpState_from + self._failresponse = self.esmtpAUTHDeclined + self._expected = [235] + challenge = base64.b64encode( + self._authinfo.challengeResponse(self.secret, 2)) + self.sendLine(b'AUTH PLAIN ' + challenge) + + + def esmtpState_challenge(self, code, resp): + self._authResponse(self._authinfo, resp) + + + def _authResponse(self, auth, challenge): + self._failresponse = self.esmtpAUTHDeclined + try: + challenge = base64.b64decode(challenge) + except binascii.Error: + # Illegal challenge, give up, then quit + self.sendLine(b'*') + self._okresponse = self.esmtpAUTHMalformedChallenge + self._failresponse = self.esmtpAUTHMalformedChallenge + else: + resp = auth.challengeResponse(self.secret, challenge) + self._expected = [235, 334] + self._okresponse = self.smtpState_maybeAuthenticated + self.sendLine(base64.b64encode(resp)) + + + def smtpState_maybeAuthenticated(self, code, resp): + """ + Called to handle the next message from the server after sending a + response to a SASL challenge. The server response might be another + challenge or it might indicate authentication has succeeded. + """ + if code == 235: + # Yes, authenticated! + del self._authinfo + self.smtpState_from(code, resp) + else: + # No, not authenticated yet. Keep trying. + self._authResponse(self._authinfo, resp) + + + +class ESMTP(SMTP): + ctx = None + canStartTLS = False + startedTLS = False + + authenticated = False + + def __init__(self, chal=None, contextFactory=None): + SMTP.__init__(self) + if chal is None: + chal = {} + self.challengers = chal + self.authenticated = False + self.ctx = contextFactory + + + def connectionMade(self): + SMTP.connectionMade(self) + self.canStartTLS = ITLSTransport.providedBy(self.transport) + self.canStartTLS = self.canStartTLS and (self.ctx is not None) + + + def greeting(self): + return SMTP.greeting(self) + b' ESMTP' + + + def extensions(self): + """ + SMTP service extensions + + @return: the SMTP service extensions that are supported. + @rtype: L{dict} with L{bytes} keys and a value of either L{None} or a + L{list} of L{bytes}. + """ + ext = {b'AUTH': _keys(self.challengers)} + if self.canStartTLS and not self.startedTLS: + ext[b'STARTTLS'] = None + return ext + + + def lookupMethod(self, command): + command = nativeString(command) + + m = SMTP.lookupMethod(self, command) + if m is None: + m = getattr(self, 'ext_' + command.upper(), None) + return m + + + def listExtensions(self): + r = [] + for (c, v) in iteritems(self.extensions()): + if v is not None: + if v: + # Intentionally omit extensions with empty argument lists + r.append(c + b' ' + b' '.join(v)) + else: + r.append(c) + + return b'\n'.join(r) + + + def do_EHLO(self, rest): + peer = self.transport.getPeer().host + + if not isinstance(peer, bytes): + peer = peer.encode('idna') + + self._helo = (rest, peer) + self._from = None + self._to = [] + self.sendCode( + 250, + (self.host + b' Hello ' + peer + b', nice to meet you\n' + + self.listExtensions()) + ) + + + def ext_STARTTLS(self, rest): + if self.startedTLS: + self.sendCode(503, b'TLS already negotiated') + elif self.ctx and self.canStartTLS: + self.sendCode(220, b'Begin TLS negotiation now') + self.transport.startTLS(self.ctx) + self.startedTLS = True + else: + self.sendCode(454, b'TLS not available') + + + def ext_AUTH(self, rest): + if self.authenticated: + self.sendCode(503, b'Already authenticated') + return + parts = rest.split(None, 1) + chal = self.challengers.get(parts[0].upper(), lambda: None)() + if not chal: + self.sendCode(504, b'Unrecognized authentication type') + return + + self.mode = AUTH + self.challenger = chal + + if len(parts) > 1: + chal.getChallenge() # Discard it, apparently the client does not + # care about it. + rest = parts[1] + else: + rest = None + self.state_AUTH(rest) + + + def _cbAuthenticated(self, loginInfo): + """ + Save the state resulting from a successful cred login and mark this + connection as authenticated. + """ + result = SMTP._cbAnonymousAuthentication(self, loginInfo) + self.authenticated = True + return result + + + def _ebAuthenticated(self, reason): + """ + Handle cred login errors by translating them to the SMTP authenticate + failed. Translate all other errors into a generic SMTP error code and + log the failure for inspection. Stop all errors from propagating. + + @param reason: Reason for failure. + """ + self.challenge = None + if reason.check(cred.error.UnauthorizedLogin): + self.sendCode(535, b'Authentication failed') + else: + log.err(reason, "SMTP authentication failure") + self.sendCode( + 451, + b'Requested action aborted: local error in processing') + + + def state_AUTH(self, response): + """ + Handle one step of challenge/response authentication. + + @param response: The text of a response. If None, this + function has been called as a result of an AUTH command with + no initial response. A response of '*' aborts authentication, + as per RFC 2554. + """ + if self.portal is None: + self.sendCode(454, b'Temporary authentication failure') + self.mode = COMMAND + return + + if response is None: + challenge = self.challenger.getChallenge() + encoded = base64.b64encode(challenge) + self.sendCode(334, encoded) + return + + if response == b'*': + self.sendCode(501, b'Authentication aborted') + self.challenger = None + self.mode = COMMAND + return + + try: + uncoded = base64.b64decode(response) + except (TypeError, binascii.Error): + self.sendCode(501, b'Syntax error in parameters or arguments') + self.challenger = None + self.mode = COMMAND + return + + self.challenger.setResponse(uncoded) + if self.challenger.moreChallenges(): + challenge = self.challenger.getChallenge() + coded = base64.b64encode(challenge) + self.sendCode(334, coded) + return + + self.mode = COMMAND + result = self.portal.login( + self.challenger, None, + IMessageDeliveryFactory, IMessageDelivery) + result.addCallback(self._cbAuthenticated) + result.addCallback(lambda ign: self.sendCode(235, + b'Authentication successful.')) + result.addErrback(self._ebAuthenticated) + + + +class SenderMixin: + """ + Utility class for sending emails easily. + + Use with SMTPSenderFactory or ESMTPSenderFactory. + """ + done = 0 + + def getMailFrom(self): + if not self.done: + self.done = 1 + return str(self.factory.fromEmail) + else: + return None + + + def getMailTo(self): + return self.factory.toEmail + + + def getMailData(self): + return self.factory.file + + + def sendError(self, exc): + # Call the base class to close the connection with the SMTP server + SMTPClient.sendError(self, exc) + + # Do not retry to connect to SMTP Server if: + # 1. No more retries left (This allows the correct error to be returned to the errorback) + # 2. retry is false + # 3. The error code is not in the 4xx range (Communication Errors) + + if (self.factory.retries >= 0 or + (not exc.retry and not (exc.code >= 400 and exc.code < 500))): + self.factory.sendFinished = True + self.factory.result.errback(exc) + + + def sentMail(self, code, resp, numOk, addresses, log): + # Do not retry, the SMTP server acknowledged the request + self.factory.sendFinished = True + if code not in SUCCESS: + errlog = [] + for addr, acode, aresp in addresses: + if acode not in SUCCESS: + errlog.append((addr + b": " + + networkString("%03d" % (acode,)) + + b" " + aresp)) + + errlog.append(log.str()) + + exc = SMTPDeliveryError(code, resp, b'\n'.join(errlog), addresses) + self.factory.result.errback(exc) + else: + self.factory.result.callback((numOk, addresses)) + + + +class SMTPSender(SenderMixin, SMTPClient): + """ + SMTP protocol that sends a single email based on information it + gets from its factory, a L{SMTPSenderFactory}. + """ + + + +class SMTPSenderFactory(protocol.ClientFactory): + """ + Utility factory for sending emails easily. + + @type currentProtocol: L{SMTPSender} + @ivar currentProtocol: The current running protocol returned by + L{buildProtocol}. + + @type sendFinished: C{bool} + @ivar sendFinished: When the value is set to True, it means the message has + been sent or there has been an unrecoverable error or the sending has + been cancelled. The default value is False. + """ + + domain = DNSNAME + protocol = SMTPSender + + def __init__(self, fromEmail, toEmail, file, deferred, retries=5, + timeout=None): + """ + @param fromEmail: The RFC 2821 address from which to send this + message. + + @param toEmail: A sequence of RFC 2821 addresses to which to + send this message. + + @param file: A file-like object containing the message to send. + + @param deferred: A Deferred to callback or errback when sending + of this message completes. + @type deferred: L{defer.Deferred} + + @param retries: The number of times to retry delivery of this + message. + + @param timeout: Period, in seconds, for which to wait for + server responses, or None to wait forever. + """ + assert isinstance(retries, (int, long)) + + if isinstance(toEmail, unicode): + toEmail = [toEmail.encode('ascii')] + elif isinstance(toEmail, bytes): + toEmail = [toEmail] + else: + toEmailFinal = [] + for _email in toEmail: + if not isinstance(_email, bytes): + _email = _email.encode('ascii') + + toEmailFinal.append(_email) + toEmail = toEmailFinal + + self.fromEmail = Address(fromEmail) + self.nEmails = len(toEmail) + self.toEmail = toEmail + self.file = file + self.result = deferred + self.result.addBoth(self._removeDeferred) + self.sendFinished = False + self.currentProtocol = None + + self.retries = -retries + self.timeout = timeout + + + def _removeDeferred(self, result): + del self.result + return result + + + def clientConnectionFailed(self, connector, err): + self._processConnectionError(connector, err) + + + def clientConnectionLost(self, connector, err): + self._processConnectionError(connector, err) + + + def _processConnectionError(self, connector, err): + self.currentProtocol = None + if (self.retries < 0) and (not self.sendFinished): + log.msg("SMTP Client retrying server. Retry: %s" % -self.retries) + + # Rewind the file in case part of it was read while attempting to + # send the message. + self.file.seek(0, 0) + connector.connect() + self.retries += 1 + elif not self.sendFinished: + # If we were unable to communicate with the SMTP server a ConnectionDone will be + # returned. We want a more clear error message for debugging + if err.check(error.ConnectionDone): + err.value = SMTPConnectError(-1, "Unable to connect to server.") + self.result.errback(err.value) + + + def buildProtocol(self, addr): + p = self.protocol(self.domain, self.nEmails*2+2) + p.factory = self + p.timeout = self.timeout + self.currentProtocol = p + self.result.addBoth(self._removeProtocol) + return p + + + def _removeProtocol(self, result): + """ + Remove the protocol created in C{buildProtocol}. + + @param result: The result/error passed to the callback/errback of + L{defer.Deferred}. + + @return: The C{result} untouched. + """ + if self.currentProtocol: + self.currentProtocol = None + return result + + + +class LOGINCredentials(_lcredentials): + """ + L{LOGINCredentials} generates challenges for I{LOGIN} authentication. + + For interoperability with Outlook, the challenge generated does not exactly + match the one defined in the + U{draft specification<http://sepp.oetiker.ch/sasl-2.1.19-ds/draft-murchison-sasl-login-00.txt>}. + """ + + def __init__(self): + _lcredentials.__init__(self) + self.challenges = [b'Password:', b'Username:'] + + + +@implementer(IClientAuthentication) +class PLAINAuthenticator: + def __init__(self, user): + self.user = user + + + def getName(self): + return b"PLAIN" + + + def challengeResponse(self, secret, chal=1): + if chal == 1: + return self.user + b'\0' + self.user + b'\0' + secret + else: + return b'\0' + self.user + b'\0' + secret + + + +class ESMTPSender(SenderMixin, ESMTPClient): + + requireAuthentication = True + requireTransportSecurity = True + + def __init__(self, username, secret, contextFactory=None, *args, **kw): + self.heloFallback = 0 + self.username = username + + if contextFactory is None: + contextFactory = self._getContextFactory() + + ESMTPClient.__init__(self, secret, contextFactory, *args, **kw) + + self._registerAuthenticators() + + + def _registerAuthenticators(self): + # Register Authenticator in order from most secure to least secure + self.registerAuthenticator(CramMD5ClientAuthenticator(self.username)) + self.registerAuthenticator(LOGINAuthenticator(self.username)) + self.registerAuthenticator(PLAINAuthenticator(self.username)) + + + def _getContextFactory(self): + if self.context is not None: + return self.context + try: + from twisted.internet import ssl + except ImportError: + return None + else: + try: + context = ssl.ClientContextFactory() + context.method = ssl.SSL.TLSv1_METHOD + return context + except AttributeError: + return None + + + +class ESMTPSenderFactory(SMTPSenderFactory): + """ + Utility factory for sending emails easily. + + @type currentProtocol: L{ESMTPSender} + @ivar currentProtocol: The current running protocol as made by + L{buildProtocol}. + """ + protocol = ESMTPSender + + def __init__(self, username, password, fromEmail, toEmail, file, + deferred, retries=5, timeout=None, + contextFactory=None, heloFallback=False, + requireAuthentication=True, + requireTransportSecurity=True): + + SMTPSenderFactory.__init__(self, fromEmail, toEmail, file, deferred, retries, timeout) + self.username = username + self.password = password + self._contextFactory = contextFactory + self._heloFallback = heloFallback + self._requireAuthentication = requireAuthentication + self._requireTransportSecurity = requireTransportSecurity + + + def buildProtocol(self, addr): + """ + Build an L{ESMTPSender} protocol configured with C{heloFallback}, + C{requireAuthentication}, and C{requireTransportSecurity} as specified + in L{__init__}. + + This sets L{currentProtocol} on the factory, as well as returning it. + + @rtype: L{ESMTPSender} + """ + p = self.protocol(self.username, self.password, self._contextFactory, + self.domain, self.nEmails*2+2) + p.heloFallback = self._heloFallback + p.requireAuthentication = self._requireAuthentication + p.requireTransportSecurity = self._requireTransportSecurity + p.factory = self + p.timeout = self.timeout + self.currentProtocol = p + self.result.addBoth(self._removeProtocol) + return p + + + +def sendmail(smtphost, from_addr, to_addrs, msg, senderDomainName=None, port=25, + reactor=reactor, username=None, password=None, + requireAuthentication=False, requireTransportSecurity=False): + """ + Send an email. + + This interface is intended to be a replacement for L{smtplib.SMTP.sendmail} + and related methods. To maintain backwards compatibility, it will fall back + to plain SMTP, if ESMTP support is not available. If ESMTP support is + available, it will attempt to provide encryption via STARTTLS and + authentication if a secret is provided. + + @param smtphost: The host the message should be sent to. + @type smtphost: L{bytes} + + @param from_addr: The (envelope) address sending this mail. + @type from_addr: L{bytes} + + @param to_addrs: A list of addresses to send this mail to. A string will + be treated as a list of one address. + @type to_addr: L{list} of L{bytes} or L{bytes} + + @param msg: The message, including headers, either as a file or a string. + File-like objects need to support read() and close(). Lines must be + delimited by '\\n'. If you pass something that doesn't look like a file, + we try to convert it to a string (so you should be able to pass an + L{email.message} directly, but doing the conversion with + L{email.generator} manually will give you more control over the process). + + @param senderDomainName: Name by which to identify. If None, try to pick + something sane (but this depends on external configuration and may not + succeed). + @type senderDomainName: L{bytes} + + @param port: Remote port to which to connect. + @type port: L{int} + + @param username: The username to use, if wanting to authenticate. + @type username: L{bytes} or L{unicode} + + @param password: The secret to use, if wanting to authenticate. If you do + not specify this, SMTP authentication will not occur. + @type password: L{bytes} or L{unicode} + + @param requireTransportSecurity: Whether or not STARTTLS is required. + @type requireTransportSecurity: L{bool} + + @param requireAuthentication: Whether or not authentication is required. + @type requireAuthentication: L{bool} + + @param reactor: The L{reactor} used to make the TCP connection. + + @rtype: L{Deferred} + @returns: A cancellable L{Deferred}, its callback will be called if a + message is sent to ANY address, the errback if no message is sent. When + the C{cancel} method is called, it will stop retrying and disconnect + the connection immediately. + + The callback will be called with a tuple (numOk, addresses) where numOk + is the number of successful recipient addresses and addresses is a list + of tuples (address, code, resp) giving the response to the RCPT command + for each address. + """ + if not hasattr(msg, 'read'): + # It's not a file + msg = BytesIO(bytes(msg)) + + + def cancel(d): + """ + Cancel the L{twisted.mail.smtp.sendmail} call, tell the factory not to + retry and disconnect the connection. + + @param d: The L{defer.Deferred} to be cancelled. + """ + factory.sendFinished = True + if factory.currentProtocol: + factory.currentProtocol.transport.abortConnection() + else: + # Connection hasn't been made yet + connector.disconnect() + + d = defer.Deferred(cancel) + + if isinstance(username, unicode): + username = username.encode("utf-8") + if isinstance(password, unicode): + password = password.encode("utf-8") + + factory = ESMTPSenderFactory(username, password, from_addr, to_addrs, msg, + d, heloFallback=True, requireAuthentication=requireAuthentication, + requireTransportSecurity=requireTransportSecurity) + + if senderDomainName is not None: + factory.domain = networkString(senderDomainName) + + connector = reactor.connectTCP(smtphost, port, factory) + + return d + + + +import codecs +def xtext_encode(s, errors=None): + r = [] + for ch in iterbytes(s): + o = ord(ch) + if ch == '+' or ch == '=' or o < 33 or o > 126: + r.append(networkString('+%02X' % (o,))) + else: + r.append(_bytesChr(o)) + return (b''.join(r), len(s)) + + + +def xtext_decode(s, errors=None): + """ + Decode the xtext-encoded string C{s}. + + @param s: String to decode. + @param errors: codec error handling scheme. + @return: The decoded string. + """ + r = [] + i = 0 + while i < len(s): + if s[i:i+1] == b'+': + try: + r.append(chr(int(bytes(s[i + 1:i + 3]), 16))) + except ValueError: + r.append(ord(s[i:i + 3])) + i += 3 + else: + r.append(bytes(s[i:i+1]).decode('ascii')) + i += 1 + return (''.join(r), len(s)) + + + +class xtextStreamReader(codecs.StreamReader): + def decode(self, s, errors='strict'): + return xtext_decode(s) + + + +class xtextStreamWriter(codecs.StreamWriter): + def decode(self, s, errors='strict'): + return xtext_encode(s) + + + +def xtext_codec(name): + if name == 'xtext': + return (xtext_encode, xtext_decode, xtextStreamReader, xtextStreamWriter) +codecs.register(xtext_codec) |