# Twisted, the Framework of Your Internet
# Copyright (C) 2001 Matthew W. Lefkowitz
# 
# This library is free software; you can redistribute it and/or
# modify it under the terms of version 2.1 of the GNU Lesser General Public
# License as published by the Free Software Foundation.
# 
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
# 
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

"""Simple Mail Transfer Protocol implementation.
"""

# Twisted imports
from twisted.protocols import basic
from twisted.internet import protocol, defer, reactor
from twisted.python import log, components
from twisted.python.compat import isinstance, StringTypes

# System imports
import time, string, re, base64, types
import MimeWriter, tempfile
import warnings

class SMTPError(Exception):
    pass

COMMAND, DATA = range(2)

class NDeferred:

    def __init__(self, n, deferred):
        self.n = n
        self.deferred = deferred
        self.done = 0

    def callback(self, arg):
        if self.done:
            return
        self.n = self.n - 1
        if self.n == 0:
            self.deferred.callback(arg)
            self.done = 1

    def errback(self, arg):
        if self.done:
            return
        self.deferred.errback(arg)
        self.done = 1

class AddressError(SMTPError):
    "Parse error in address"


# Character classes for parsing addresses
atom = r"[-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.
    """

    tstring = re.compile(r'''( # A string of
                          (?:"[^"]*" # quoted string
                          |\\. # backslash-escaped characted
                          |''' + atom + r''' # atom character
                          )+|.) # or any single character''',re.X)
    atomre = re.compile(atom) # match any one atom character

    def __init__(self, addr):
        self.local = ''
        self.domain = ''
        self.addrstr = addr

        # Tokenize
        atl = filter(None,self.tstring.split(addr))

        local = []
        domain = []

        while atl:
            if atl[0] == '<':
                if atl[-1] != '>':
                    raise AddressError, "Unbalanced <>"
                atl = atl[1:-1]
            elif atl[0] == '@':
                atl = atl[1:]
                if not local:
                    # Source route
                    while atl and atl[0] != ':':
                        # 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 = ['']
            elif len(atl[0]) == 1 and not self.atomre.match(atl[0]) \
                     and not atl[0] ==  '.':
                raise AddressError, "Parse error at " + atl[0]
            else:
                if not domain:
                    local.append(atl[0])
                else:
                    domain.append(atl[0])
                atl = atl[1:]
               
        self.local = ''.join(local)
        self.domain = ''.join(domain)

    dequotebs = re.compile(r'\\(.)')

    def dequote(self,addr):
        """Remove RFC-2821 quotes from address."""
        res = []

        atl = filter(None,self.tstring.split(str(addr)))

        for t in atl:
            if t[0] == '"' and t[-1] == '"':
                res.append(t[1:-1])
            elif '\\' in t:
                res.append(self.dequotebs.sub(r'\1',t))
            else:
                res.append(t)

        return ''.join(res)

    def __str__(self):
        return '%s%s' % (self.local, self.domain and ("@" + self.domain) or "")

    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):
        self.dest = Address(destination)
        self.helo = helo
        self.protocol = protocol
        if isinstance(orig, StringTypes):
            self.orig = Address(orig)
        else:
            self.orig = orig

    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 str(self.dest)

    def __getattr__(self,attr):
        attrmap = { 'name' : 'local', 'domain' : 'domain' }
        if attr in attrmap:
            warnings.warn("User.%s is deprecated, use User.dest.%s instead" %
                (attr, attrmap[attr]), category=DeprecationWarning,
                stacklevel=2)
            return getattr(self.dest, attrmap[attr])
        else:
            raise AttributeError, ("'%s' object has no attribute '%s'" %
                (type(self).__name__, attr))

class IMessage(components.Interface):
    """Interface definition for messages that can be sent via SMTP."""
    
    def lineReceived(self, line):
        """handle another line"""

    def eomReceived(self):
        """handle end of message

        return a deferred. The deferred should be called with either:
        callback(string) or errback(error)
        """

    def connectionLost(self):
        """handle message truncated

        semantics should be to discard the message
        """

class SMTP(basic.LineReceiver):
    """SMTP server-side protocol."""
    
    def __init__(self):
        self.mode = COMMAND
        self.__from = None
        self.__helo = None
        self.__to = []

    def timedout(self):
        self.sendCode(421, '%s Timeout. Try talking faster next time!' %
                      self.host)
        self.transport.loseConnection()

    def connectionMade(self):
        self.host = self.factory.domain
        if hasattr(self.factory, 'timeout'):
            self.timeout = self.factory.timeout
        else:
            self.timeout = 600
        self.sendCode(220, '%s Spammers beware, your ass is on fire' %
                      self.host)
        if self.timeout:
            self.timeoutID = reactor.callLater(self.timeout, self.timedout)

    def sendCode(self, code, message=''):
        "Send an SMTP code with a message."
        lines = message.splitlines()
        lastline = lines[-1:]
        for line in lines[:-1]:
            self.transport.write('%3.3d-%s\r\n' % (code, line))
        self.transport.write('%3.3d %s\r\n' % (code,
                                               lastline and lastline[0] or ''))

    def lineReceived(self, line):
        if self.timeout:
            self.timeoutID.cancel()
            self.timeoutID = reactor.callLater(self.timeout, self.timedout)

        if self.mode is DATA:
            return self.dataLineReceived(line)
        if line:
            command = string.split(line, None, 1)[0]
        else:
            command = ''
        method = getattr(self, 'do_'+string.upper(command), None)
        if method is None:
            method = self.do_UNKNOWN
        else:
            line = line[len(command):]
        return method(string.strip(line))

    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, 'Line too long')

    def rawDataReceived(self, data):
        """Throw away rest of long line"""
        rest = string.split(data, '\r\n', 1)
        if len(rest) == 2:
            self.setLineMode(rest[1])

    def do_UNKNOWN(self, rest):
        self.sendCode(500, 'Command not implemented')

    def do_HELO(self, rest):
        peer = self.transport.getPeer()[1]
        self.__helo = (rest, peer)
        self.sendCode(250, '%s Hello %s, nice to meet you' % (self.host, peer))

    def do_QUIT(self, rest):
        self.sendCode(221, 'See you later')
        self.transport.loseConnection()

    # A string of quoted strings, backslash-escaped character or
    # atom characters + '@.,:'
    qstring = r'("[^"]*"|\\.|' + atom + r'|[@.,:])+'

    mail_re = re.compile(r'''\s*FROM:\s*(?P<path><> # Empty <>
                         |<''' + qstring + r'''> # <addr>
                         |''' + qstring + r''' # addr
                         )\s*(\s(?P<opts>.*))? # Optional WS + ESMTP options
                         $''',re.I|re.X)
    rcpt_re = re.compile(r'\s*TO:\s*(?P<path><' + qstring + r'''> # <addr>
                         |''' + qstring + r''' # addr
                         )\s*(\s(?P<opts>.*))? # Optional WS + ESMTP options
                         $''',re.I|re.X)

    def do_MAIL(self, rest):
        if self.__from:
            self.sendCode(503,"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, "Syntax error")
            return

        try:
            addr = Address(m.group('path'))
        except AddressError, e:
            self.sendCode(553, str(e))
            return
            
        self.validateFrom(self.__helo, addr, self._fromValid,
                          self._fromInvalid)

    def _fromValid(self, from_, code=250, msg='From address accepted'):
        self.__from = from_
        self.sendCode(code, msg)

    def _fromInvalid(self, from_, code=550, msg='No mail for you!'):
        self.sendCode(code,msg)

    def do_RCPT(self, rest):
        if not self.__from:
            self.sendCode(503, "Must have sender before recipient")
            return
        m = self.rcpt_re.match(rest)
        if not m:
            self.sendCode(501, "Syntax error")
            return

        try:
            user = User(m.group('path'), self.__helo, self, self.__from)
        except AddressError, e:
            self.sendCode(553, str(e))
            return
            
        self.validateTo(user, self._toValid, self._toInvalid)

    def _toValid(self, to, code=250, msg='Address recognized'):
        self.__to.append(to)
        self.sendCode(code, msg)

    def _toInvalid(self, to, code=550,
                   msg='Cannot receive for specified address'):
        self.sendCode(code, msg)

    def do_DATA(self, rest):
        if self.__from is None or not self.__to:  
            self.sendCode(503, 'Must have valid receiver and originator')
            return
        self.mode = DATA
        helo, origin, recipients = self.__helo, self.__from, self.__to
        self.__from = None
        self.__to = []
        self.__messages = self.startMessage(recipients)
        self.__inheader = self.__inbody = 0
        for message in self.__messages:
            message.lineReceived(self.receivedHeader(helo, origin, recipients))
        self.sendCode(354, 'Continue')

    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:
                    message.connectionLost()
                del self.__messages
            except AttributeError:
                pass

    def do_RSET(self, rest):
        self.__from = None
        self.__to = []
        self.sendCode(250, 'I remember nothing.')

    def dataLineReceived(self, line):
        if line[:1] == '.':
            if line == '.':
                self.mode = COMMAND
                if not self.__messages:
                    self._messageHandled("thrown away")
                    return
                deferred = defer.Deferred()
                deferred.addCallback(self._messageHandled)
                deferred.addErrback(self._messageNotHandled)
                ndeferred = NDeferred(len(self.__messages), deferred)
                for message in self.__messages:
                    deferred = message.eomReceived()
                    deferred.addCallback(ndeferred.callback)
                    deferred.addErrback(ndeferred.errback)
                del self.__messages
                return
            line = line[1:]

        # 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 ':' in line:
                self.__inheader = 1
            elif line:
                for message in self.__messages:
                    message.lineReceived('')
                self.__inbody = 1

        if not line:
            self.__inbody = 1
        
        for message in self.__messages:
            message.lineReceived(line)

    def _messageHandled(self, _):
        self.sendCode(250, 'Delivery in progress')

    def _messageNotHandled(self, _):
        self.sendCode(550, 'Could not send e-mail')

    def rfc822date(self):
        timeinfo = time.localtime()
        if timeinfo[8]:
            # DST
            tz = -time.altzone
        else:
            tz = -time.timezone
            
        return "%s %+2.2d%2.2d" % (
            time.strftime("%a, %d %b %Y %H:%M:%S", timeinfo),
            tz / 3600, (tz / 60) % 60)

    # overridable methods:
    def receivedHeader(self, helo, origin, recipents):
        return "Received: From %s ([%s]) by %s; %s" % (
            helo[0], helo[1], self.host, self.rfc822date())
    
    def validateFrom(self, helo, origin, success, failure):
        if not helo:
            failure(origin,503,"Who are you? Say HELO first");
            return
        success(origin)

    def validateTo(self, user, success, failure):
        success(user)

    def startMessage(self, recipients):
        return []


class SMTPFactory(protocol.ServerFactory):
    """Factory for SMTP."""

    # override in instances or subclasses
    domain = "localhost"
    timeout = 600

    protocol = SMTP


class SMTPClient(basic.LineReceiver):
    """SMTP client for sending emails."""

    def __init__(self, identity):
        self.identity = identity

    def connectionMade(self):
        self.state = 'helo'

    def lineReceived(self, line):
        if len(line)<4 or (line[3] not in ' -'):
            raise ValueError("invalid line from SMTP server %s" % line)
        if line[3] == '-':
            return
        code = int(line[:3])
        method =  getattr(self, 'smtpCode_%d_%s' % (code, self.state), 
                                self.smtpCode_default)
        method(line[4:])

    def smtpCode_220_helo(self, line):
        self.sendLine('HELO '+self.identity)
        self.state = 'from'

    def smtpCode_250_from(self, line):
        from_ = self.getMailFrom()
        if from_ is not None:
            self.sendLine('MAIL FROM:<%s>' % from_)
            self.state = 'afterFrom'
        else:
            self.sendLine('QUIT')
            self.state = 'quit'

    def smtpCode_250_afterFrom(self, line):
        self.toAddresses = self.getMailTo()
        self.successAddresses = []
        self.state = 'to'
        self.sendToOrData()

    def smtpCode_221_quit(self, line):
        self.transport.loseConnection()

    def smtpCode_default(self, line):
        log.msg("SMTPClient got unexpected message from server -- %s" % line)
        self.transport.loseConnection()

    def sendToOrData(self):
        if not self.toAddresses:
            if self.successAddresses:
                self.sendLine('DATA')
                self.state = 'data'
            else:
                self.sentMail([])
                self.smtpCode_250_from('')
        else:
            self.lastAddress = self.toAddresses.pop()
            self.sendLine('RCPT TO:<%s>' % self.lastAddress)

    def smtpCode_250_to(self, line):
        self.successAddresses.append(self.lastAddress)
        self.sendToOrData()

    def smtpCode_550_to(self, line):
        self.sendToOrData()
        
    def smtpCode_354_data(self, line):
        self.mailFile = self.getMailData()
        self.lastsent = ''
        self.transport.registerProducer(self, 0)

    def smtpCode_250_afterData(self, line):
        self.sentMail(self.successAddresses)
        self.smtpCode_250_from('')

    # IProducer interface
    def resumeProducing(self):
        """Write another """
        chunk = self.mailFile.read(8192)
        if not chunk:
            self.transport.unregisterProducer()
            if self.lastsent != '\n':
                line = '\r\n.'
            else:
                line = '.'
            self.sendLine(line)
            self.state = 'afterData'
            return

        chunk = string.replace(chunk, "\n", "\r\n")
        chunk = string.replace(chunk, "\r\n.", "\r\n..")
        self.transport.write(chunk)
        self.lastsent = chunk[-1]

    def pauseProducing(self):
        pass

    def stopProducing(self):
        self.mailFile.close()


    # these methods should be overriden 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.

        The file should be a text file with local line ending convention,
        i.e. readline() should return a line ending in '\\n'.
        """
        raise NotImplementedError

    def sentMail(self, addresses):
        """Called with list of emails to which we sent the message."""
        pass


class SMTPSender(SMTPClient):
    """Utility class for sending emails easily - use with SMTPSenderFactory."""
    
    done = 0

    def smtpCode_default(self, line):
        """Deal with unexpected SMTP messages."""
        SMTPClient.smtpCode_default(self, line)
        self.sentMail([])
    
    def getMailFrom(self):
        if not self.done:
            self.done = 1
            return self.factory.fromEmail
        else:
            return None

    def getMailTo(self):
        return [self.factory.toEmail]

    def getMailData(self):
        return self.factory.file

    def sentMail(self, addresses):
        self.factory.sendFinished = 1
        self.factory.result.callback(addresses == [self.factory.toEmail])


class SMTPSenderFactory(protocol.ClientFactory):
    """
    Utility factory for sending emails easily.
    """

    protocol = SMTPSender
    
    def __init__(self, fromEmail, toEmail, file, deferred):
        self.fromEmail = fromEmail
        self.toEmail = toEmail
        self.file = file
        self.result = deferred
        self.sendFinished = 0
    
    def clientConnectionFailed(self, connector, error):
        self.result.errback(error)

    def clientConnectionLost(self, connector, error):
        # if email wasn't sent, try again
        if not self.sendFinished:
            connector.connect() # reconnect to SMTP server

    def buildProtocol(self, addr):
        p = self.protocol(self.fromEmail.split('@')[-1])
        p.factory = self
        return p


def sendEmail(smtphost, fromEmail, toEmail, content, headers = None, attachments = None, multipartbody = "mixed"):
    """Send an email, optionally with attachments.

    @type smtphost: str
    @param smtphost: hostname of SMTP server to which to connect
    
    @type fromEmail: str
    @param fromEmail: email address to indicate this email is from
    
    @type toEmail: str
    @param toEmail: email address to which to send this email
    
    @type content: str
    @param content: The body if this email.
    
    @type headers: dict
    @param headers: Dictionary of headers to include in the email

    @type attachments: list of 3-tuples
    @param attachments: Each 3-tuple should consist of the name of the
      attachment, the mime-type of the attachment, and a string that is
      the attachment itself.

    @type multipartbody: str
    @param multipartbody: The type of MIME multi-part body.  Generally
      either "mixed" (as in text and images) or "alternative" (html email
      with a fallback to text/plain).

    @rtype: Deferred
    @return: The returned Deferred has its callback or errback invoked when
      the mail is successfully sent or when an error occurs, respectively.
    """
    f = tempfile.TemporaryFile()
    writer = MimeWriter.MimeWriter(f)

    writer.addheader("Mime-Version", "1.0")
    if headers:
        # Setup the mail headers
        for (header, value) in headers.items():
            writer.addheader(header, value)

    writer.startmultipartbody(multipartbody)

    # message body
    part = writer.nextpart()
    body = part.startbody("text/plain")
    body.write(content)

    if attachments is not None:
        # add attachments
        for (file, mime, attachment) in attachments:
            part = writer.nextpart()
            if mime.startswith('text'):
                encoding = "7bit"
            else:
                attachment = base64.encodestring(attachment)
                encoding = "base64"
            part.addheader("Content-Transfer-Encoding", encoding)
            body = part.startbody("%s; name=%s" % (mime, file))
            body.write(attachment)

    # finish
    writer.lastpart()

    # send message
    f.seek(0, 0)
    d = defer.Deferred()
    factory = SMTPSenderFactory(fromEmail, toEmail, f, d)
    reactor.connectTCP(smtphost, 25, factory)

    return d
