File: class/Extras/Code/pp3e/mailtools.py

########################################
# interface to mail server transfers
# designed to be mixed-in to subclasses
# which use the methods defined here;
# class allows askPopPassword to differ
# loads all if server doesn't do top
# doesn't handle threads or UI here
# progress callback funcs get status
# all calls raise exceptions on error
########################################
#???? saveparts: open in text mode for text/ contypes?

import mailconfig
import poplib, smtplib, os, mimetypes
import email.Parser, email.Utils, email.Encoders

from email.Message import Message
from email.MIMEMultipart import MIMEMultipart
from email.MIMEAudio import MIMEAudio
from email.MIMEImage import MIMEImage
from email.MIMEText import MIMEText
from email.MIMEBase import MIMEBase

class MailTool:                    # super class for all mail tools
    def trace(self, message):      # redef me to disable or log to file
        print message


####################################
# parsing and attachment extraction
####################################


class MailParser(MailTool):
    """
    methods for parsing message text, attachements

    subtle thing: Message object payloads are either a simple
    string for non-multipart messages, or a list of Message
    objects if multipart (possiby nested); we don't need to
    distinguish between the two cases here, because the Message
    walk generator always returns self first, and so works fine
    on non-multipart messages too (a single object is walked);

    for simple messages, the message body is always considered
    here to be the sole part of the mail;  for miltipart messages,
    the parts list includes the main message text, as well as all
    attachments;  this allows simple messages not of type text to
    be handled like attachments in a UI (e.g., saved, opened).
    """
    
    def saveParts(self, savedir, message):
        """
        store all parts of a message as files in a local directory;
        returns [('maintype/subtype', 'filename')] list for use by
        callers, but does not open any parts or attachments here;
        """
        if not os.path.exists(savedir):
            os.mkdir(savedir)
        partfiles = []
        for (ix, part) in enumerate(message.walk()):    # walk includes message
            maintype = part.get_content_maintype()      # ix includes multiparts 
            if maintype == 'multipart':
                continue                                # multipart/*: container
            else:                                       # else save part to file
                filename, contype = self.partName(part, ix)
                fullname = os.path.join(savedir, filename)
                fileobj  = open(fullname, 'wb')             # use binary mode
                fileobj.write(part.get_payload(decode=1))   # decode base64,etc
                fileobj.close()
                partfiles.append((contype, fullname))       # for caller to open
        return partfiles
    
    def partsList(self, message):
        """"
        return a list of filenames for all parts of an
        already-parsed message, using same file name logic
        as saveParts, but do not store the part files here
        """
        partfiles = []
        for (ix, part) in enumerate(message.walk()):
            maintype = part.get_content_maintype()
            if maintype == 'multipart':
                continue
            filename, contype = self.partName(part, ix)
            partfiles.append(filename)
        return partfiles

    def partName(self, part, ix):
        """
        extract filename and content type from message part;
        filename: tries Content-Disposition, then Content-Type
        name param, or generates one based on mimetype guess;
        """
        filename = part.get_filename()                # filename in msg hdrs?
        contype  = part.get_content_type()            # lower maintype/subtype
        if not filename:
            filename = part.get_param('name')         # try content-type name
        if not filename:
            if contype == 'text/plain':               # hardcode plain text ext
                ext = '.txt'                          # else guesses .ksh!
            else:
                ext = mimetypes.guess_extension(contype)
                if not ext: ext = '.bin'              # use a generic default
            filename = 'part-%03d%s' % (ix, ext)
        return (filename, contype)

    def findMainText(self, message):
        """
        for text-oriented clients, return the first text part;
        for the payload of a simple message, or all parts of
        a multipart message, looks for text/plain, then text/html,
        then text/*, before deducing that there is no text to
        display;  this is a heuristic, but covers most simple,
        multipart/alternative, and multipart/mixed messages;
        content-type defaults to text/plain if none in simple msg;        

        handles message nesting at top level by walking instead
        of list scans;  if non-multipart but type is text/html,
        returns the htlm as the text with an html type: caller
        may open in webbrowser;  if non-multipart and not text,
        no text to display: save/open in UI;  caveat: does not
        try to concatenate multiple inline text/plain parts
        """
        # try to find a plain text
        for part in message.walk():                        # walk visits messge
            type = part.get_content_type()                 # if non-multipart
            if type == 'text/plain':
                return type, part.get_payload(decode=1)    # may be base64

        # try to find a html part
        for part in message.walk():
            type = part.get_content_type()
            if type == 'text/html':
                return type, part.get_payload(decode=1)    # caller renders

        # try any other text type, including xml
        for part in message.walk():
            if part.get_content_maintype() == 'text':
                return part.get_content_type(), part.get_payload(decode=1)

        # punt: could use first part, but it's not marked as text 
        return 'text/plain', '[No text to display]'

    # returned when parses fail
    errorMessage = Message()
    errorMessage.set_payload('[Unable to parse message - format error]')
    
    def parseHeaders(self, mailtext):
        """
        parse headers only, return root email.Message object
        stops after headers parsed, even if nothing else follows (top)
        email.Message object is a mapping for mail header fields
        payload of message object is None, not raw body text
        """
        try:
            return email.Parser.Parser().parsestr(mailtext, headersonly=True)
        except:
            return self.errorMessage

    def parseMessage(self, fulltext):
        """
        parse entire message, return root email.Message object
        payload of message object is a string if not is_multipart()
        payload of message object is more Messages if multiple parts
        the call here same as calling email.message_from_string()
        """
        try:
            return email.Parser.Parser().parsestr(fulltext)       # may fail!
        except:
            return self.errorMessage     # or let call handle? can check return

    def parseMessageRaw(self, fulltext):
        """
        parse headers only, return root email.Message object
        stops after headers parsed, for efficiency (not yet used here)
        payload of message object is raw text of mail after headers
        """
        try:
            return email.Parser.HeaderParser().parsestr(fulltext)
        except:
            return self.errorMessage


####################################
# send messages, add attachments
####################################


class MailSender(MailTool):
    """
    send mail: format message, interface with SMTP server
    works on any machine with Python+Inet, doesn't use cmdline mail
    """
    def __init__(self, smtpserver=None):
        self.smtpServerName  = smtpserver or mailconfig.smtpservername

    def sendMessage(self, From, To, Subj, extrahdrs, bodytext, attaches):
        """
        format,send mail: blocks caller, thread me in a gui
        bodytext is main text part, attaches is list of filenames
        extrahdrs is list of (name, value) tuples to be added
        raises uncaught exception if send fails for any reason
        
        assumes that To, Cc, Bcc hdr values are lists of 1 or more already
        stripped addresses (possibly in full name+<addr> format); client
        must split these on delimiters, parse, or use multi-line input;
        note that smtp allows full name+<addr> format in recipients
        """
        if not attaches:
            msg = Message()
            msg.set_payload(bodytext)
        else:
            msg = MIMEMultipart()
            self.addAttachments(msg, bodytext, attaches)

        recip = To
        msg['From']    = From
        msg['To']      = ', '.join(To)              # poss many: addr list
        msg['Subject'] = Subj                       # servers reject ';' sept
        msg['Date']    = email.Utils.formatdate()   # curr datetime, rfc2822 utc
        for name, value in extrahdrs:               # Cc, Bcc, X-Mailer, etc.
            if value:
                if name.lower() not in ['cc', 'bcc']:
                    msg[name] = value
                else:
                    msg[name] = ', '.join(value)    # add commas between
                    recip += value                  # some servers reject ['']
        fullText = msg.as_string()                  # generate formatted msg

        # sendmail call raises except if all Tos failed,
        # or returns failed Tos dict for any that failed
        
        self.trace('Sending to...'+ str(recip))
        self.trace(fullText[:256])
        server = smtplib.SMTP(self.smtpServerName)           # this may fail too
        try:
            failed = server.sendmail(From, recip, fullText)  # except or dict
        finally:
            server.quit()                                    # iff connect okay
        if failed:
            class SomeAddrsFailed(Exception): pass
            raise SomeAddrsFailed('Failed addrs:%s\n' % failed)
        self.trace('Send exit')

    def addAttachments(self, mainmsg, bodytext, attaches):
        # format a multi-part message with attachments
        msg = MIMEText(bodytext)                 # add main text/plain part
        mainmsg.attach(msg)
        for filename in attaches:                # absolute or relative paths
            if not os.path.isfile(filename):     # skip dirs, etc.
                continue
            
            # guess content type from file extension, ignore encoding
            contype, encoding = mimetypes.guess_type(filename)
            if contype is None or encoding is not None:  # no guess, compressed?
                contype = 'application/octet-stream'     # use generic default
            self.trace('Adding ' + contype)

            # build sub-Message of apropriate kind
            maintype, subtype = contype.split('/', 1)
            if maintype == 'text':
                data = open(filename, 'r')
                msg  = MIMEText(data.read(), _subtype=subtype)
                data.close()
            elif maintype == 'image':
                data = open(filename, 'rb')
                msg  = MIMEImage(data.read(), _subtype=subtype)
                data.close()
            elif maintype == 'audio':
                data = open(filename, 'rb')
                msg  = MIMEAudio(data.read(), _subtype=subtype)
                data.close()
            else:
                data = open(filename, 'rb')
                msg  = MIMEBase(maintype, subtype)
                msg.set_payload(data.read())
                data.close()                            # make generic type
                email.Encoders.encode_base64(msg)       # encode using Base64

            # set filename and attach to container
            basename = os.path.basename(filename)
            msg.add_header('Content-Disposition',
                           'attachment', filename=basename)
            mainmsg.attach(msg)

        # text outside mime structure, seen by non-MIME mail readers
        mainmsg.preamble = 'A multi-part MIME format message.\n'
        mainmsg.epilogue = ''  # make sure message ends with a newline


####################################
# retrieve mail from a pop server
####################################


class MailFetcher(MailTool):
    """
    fetch mail: connect, fetch headers+mails, delete mails
    works on any machine with Python+Inet; subclass me to cache
    implemented with the POP protocol; IMAP requires new class
    """
    def __init__(self, popserver=None, popuser=None, hastop=True):
        self.popServer   = popserver or mailconfig.popservername
        self.popUser     = popuser   or mailconfig.popusername
        self.srvrHasTop  = hastop
        self.popPassword = None
                 
    def connect(self):
        self.trace('Connecting...')
        self.getPassword()                          # file, gui, or console
        server = poplib.POP3(self.popServer)  
        server.user(self.popUser)                   # connect,login pop server
        server.pass_(self.popPassword)              # pass is a reserved word
        self.trace(server.getwelcome())             # print returned greeting 
        return server

    def downloadMessage(self, msgnum):
        # load full text of one msg
        self.trace('load '+str(msgnum))
        server = self.connect()
        try:
            resp, msglines, respsz = server.retr(msgnum)
        finally:
            server.quit()
        return '\n'.join(msglines)                 # concat lines for parsing

    def downloadAllHeaders(self, progress=None, loadfrom=1):
        """
        get sizes, headers only, for all or new msgs
        begins loading from message number loadfrom
        use loadfrom to load newly-arrived mails only
        use downloadMessage to get the full msg text
        """
        if not self.srvrHasTop:                    # not all servers support TOP
            return self.downloadAllMsgs(progress)  # naively load full msg text
        else:
            self.trace('loading headers')
            server = self.connect()                # mbox now locked until quit
            try:
                resp, msginfos, respsz = server.list()   # 'num size' lines list
                msgCount = len(msginfos)                 # alt to srvr.stat[0]
                msginfos = msginfos[loadfrom-1:]         # drop already-loadeds
                allsizes = [int(x.split()[1]) for x in msginfos]
                allhdrs  = []
                for msgnum in range(loadfrom, msgCount+1):          # poss empty
                    if progress: progress(msgnum, msgCount)         # callback?
                    resp, hdrlines, respsz = server.top(msgnum, 0)  # hdrs only
                    allhdrs.append('\n'.join(hdrlines))
            finally:
                server.quit()                          # make sure unlock mbox
            assert len(allhdrs) == len(allsizes)
            self.trace('load headers exit')
            return allhdrs, allsizes, False

    def downloadAllMessages(self, progress=None, loadfrom=1):
        # load full message text for all msgs, despite any caching
        self.trace('loading full messages')
        server = self.connect()
        try:
            (msgCount, msgBytes) = server.stat()
            allmsgs  = []
            allsizes = []
            for i in range(loadfrom, msgCount+1):         # empty if low >= high
                if progress: progress(i, msgCount)
                (resp, message, respsz) = server.retr(i)  # save text on list
                allmsgs.append('\n'.join(message))        # leave mail on server
                allsizes.append(respsz)                   # diff from len(msg)
        finally:
            server.quit()                                 # unlock the mail box
        assert len(allmsgs) == (msgCount - loadfrom) + 1  # msg nums start at 1
        assert sum(allsizes) == msgBytes
        return allmsgs, allsizes, True

    def deleteMessages(self, msgnums, progress=None):
        # delete multiple msgs off server
        server = self.connect()
        try:
            for (ix, msgnum) in enumerate(msgnums):   # dont reconnect for each
                if progress: progress(ix+1, len(msgnums))
                server.dele(msgnum)                     
        finally:                                      # changes msgnums: reload
            server.quit()
        
    def getPassword(self):
        """
        get pop password if not yet known
        not required until go to server
        from client-side file or subclass method
        """
        if not self.popPassword:
            try:
                localfile = open(mailconfig.poppasswdfile)
                self.popPassword = localfile.readline()[:-1]
                self.trace('local file password' + repr(self.popPassword))
            except:
                self.popPassword = self.askPopPassword()

    def askPopPassword(self):
        assert False, 'Subclass must define method'


class MailFetcherConsole(MailFetcher):
    def askPopPassword(self):
        import getpass
        prompt = 'Password for %s on %s?' % (self.popUser, self.popServer)
        return getpass.getpass(prompt)


##################################
# self-test when run as a program
##################################


if __name__ == '__main__':
    sender = MailSender()
    sender.sendMessage(From      = mailconfig.myaddress,
                       To        = [mailconfig.myaddress],
                       Subj      = 'testing 123',
                       extrahdrs = [('X-Mailer', 'mailtools.py')],
                       bodytext  = 'Here is my source code',
                       attaches  = ['mailtools.py'])

    fetcher = MailFetcherConsole()
    def status(*args): print args
    
    hdrs, sizes, all = fetcher.downloadAllHeaders(status)
    for num, hdr in enumerate(hdrs[:5]):
        print hdr
        if raw_input('load mail?') in ['y', 'Y']:
            print fetcher.downloadMessage(num+1), '\n', '-'*70
            
    msgs, sizes, all = fetcher.downloadAllMessages(status)
    for msg in msgs[:5]:
        print msg, '\n', '-'*70
    raw_input('Press Enter to exit')



[Home page] Books Code Blog Python Author Train Find ©M.Lutz