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')