###################################################### # PyMailGui 1.0 - A Python/Tkinter email client. # # Adds a Tkinter-based GUI interface to the pymail # script's functionality. Works for POP/SMTP based # email accounts using sockets on the machine on # which this script is run. # # I like this script for two main reasons: # 1) It's scriptable--you control its evolution # from this point forward, and can easily customize # and program it by editing the Python code in this # file, unlike canned products like MS-Outlook. # 2) It's portable--this script can be used to # process your email on any machine with internet # sockets where Python and Tkinter are installed; # use the simple command-line based pymail.py if # you have sockets and Python but not Tkinter. # E.g., I use this to read my pop email account # from UNIX machines when I'm away from home PC. # # Caveats: # - Only fetches new mail on 'Load' button for speed, # but that assumes nothing else is changing the pop # mailbox while the gui is running. No other # email tools (or PyMailGui instances) can delete # mail off the pop server while gui runs, else mail # numbers displayed are invalid. Note that the email # box is locked during deletes too: incoming mail won't # invalidate numbers, since added to end when unlocked. # - Run me from command line or file explorer to watch # my printed messages (not visible from launchers). # - On a Save, the file select dialog pops up a # warning about replacing an existing file if one is # chosen, even though messages are simply appended # ('a' mode) to the existing file; this is a Tk # issue (Tkinter calls TK's tk_getSaveFile command). # - Save also doesn't remember the directory where a # file was last saved (You need to renavigate from the # examples root); to rememebr the last selected dir, # make and save a tkFileDialog.SaveAs class object, # and call its show( ) method to get a filename, # instead of calling tkFileDialog.asksaveasfilename. # Change: this program now implements this scheme. # # On long-running callbacks (load, send, delete): # Handles load/send/delete wait states by running the # blocking call in a non-gui thread, and posting a # modal dialog box during the wait (else the GUI is # hung while the blocking call runs). The main # thread creates windows and process all gui events, # and the non-gui thread does the long-running # non-gui call and signals completion on exit. # The main thread uses a busy widget.update loop # to process events during the wait instead of # wait_variable(), because the non-gui thread # can't set tk variables in Windows either. # # A prior version tried to update the gui during the # wait by starting a thread that woke up N time per # sec to call rootWin.update(); this fails badly on # Windows. Apparently, the rule on Windows is that # only the thread that creates a window can handle # events for it; hence, new threads can't call for # widget.update() to update the gui (or do other GUI # work), as that call triggers gui processing in the # main thread. Worse, threads can't set Tk variables # reliably either (this goes to the main thread too), # so I use simple shared global variables and busy # wait loops here to signal thread exit, rather than # Tk vars + widget.wait_variable(); the latter is # easier (the Tk event loop is active during a wait), # but fails with Windows threads. thread module # lockas and threading module conditions/joins may # have been used to signal thread exit too. # # Note that this is a Windows issue (not Tk), and it # is okay to spawn new threads under Tk as long as they # are strictly non-gui. Also note that in principle, # we could run both a load and send thread at the same # time (ex: write new email while loading); instead, # the main GUI Box is disabled while any load or # save threads runs, to avoid more mutex issues. # Finally, we can also implement a wait state with # the tk widget.after(msec, func) call, scheduling # func to check the thread exit var every N msecs # (see other examples for use), rather than a local # widget.update() event dispatch loop as done here. # func could destroy the popup window on thread exit. # That requires some recoding though, as callers now # assume that busy states return after thread exit. # # There is much room for improvement and new features # here--left as exercises. Example extensions: # - Add an automatic spam filter, which matches # from/to hdrs etc. with a regex and auto deletes # matching messages as they are being downloaded; # - Do attachments: auto decoding/unpacking for # multi-part emails, etc.: see decode*.py here # and module mimetools for attachment hints; # - Fields in the main list box could be padded to # a standard length or put in distinct widgets. # - Make me a class to avoid global vars, and make # it easier to attach this GUI to another one; # that would also allow creation of more than one # mail client gui per process, but this won't work # as currently designed (deletes in one gui can # invalidate others, due to new pop msg numbers); # - Inherit from GuiMaker here to get menu/toolbars; # - Write a remote CGI-based file tool, which does # pop/smtp too, but runs on a server and interacts # with users by generating html pages instead of # running a GUI on the local client machine--see # example PyMailCgi in the CGI section of the book. # This approach is limited in functionality, but # Python has to be installed on the server only. ###################################################### # get services import pymail, mailconfig import rfc822, StringIO, string, sys, thread from Tkinter import * from tkFileDialog import asksaveasfilename, SaveAs from tkMessageBox import showinfo, showerror, askyesno # init global/module vars msgList = [] # list of retrieved emails text toDelete = [] # msgnums to be deleted on exit listBox = None # main window's scrolled msg list rootWin = None # the main window of this program allModeVar = None # for All mode checkbox value threadExitVar = 0 # used to signal thread exit mailserver = mailconfig.popservername # where to read pop email from mailuser = mailconfig.popusername # smtp server in mailconfig too mailpswd = None # passwd input via a popup here #mailfile = mailconfig.savemailfile # from a file select dialog here def fillIndex(msgList): # fill all of main listbox listBox.delete(0, END) count = 1 for msg in msgList: hdrs = rfc822.Message(StringIO.StringIO(msg)) msginfo = '%02d' % count for key in ('Subject', 'From', 'Date'): if hdrs.has_key(key): msginfo = msginfo + ' | ' + hdrs[key][:30] listBox.insert(END, msginfo) count = count+1 listBox.see(END) # show most recent mail=last line def selectedMsg(): # get msg selected in main listbox # print listBox.curselection() if listBox.curselection() == (): return 0 # empty tuple:no selection else: # else zero-based index return eval(listBox.curselection()[0]) + 1 # in a 1-item tuple of str def waitForThreadExit(win): import time global threadExitVar # in main thread, watch shared global var delay = 0.0 # at least on Windows no sleep is needed while not threadExitVar: win.update() # dispatch any new GUI events during wait time.sleep(delay) # if needed, sleep so other thread can run threadExitVar = 0 def busyInfoBoxWait(message): # popup wait message box, wait for a thread exit # main gui event thread stays alive during wait # as coded returns only after thread has finished popup = Toplevel() popup.title('PyMail Wait') popup.protocol('WM_DELETE_WINDOW', lambda:0) # ignore deletes label = Label(popup, text=message+'...') label.config(height=10, width=40, cursor='watch') # busy cursor label.pack() popup.focus_set() # grab application popup.grab_set() # wait for thread exit #popup.wait_variable(threadExitVar) # gui alive during wait waitForThreadExit(popup) # tk vars fail on win32 print 'thread exit caught' popup.destroy() def loadMailThread(): # load mail while main thread handles gui events global msgList, errInfo, threadExitVar print 'load start' errInfo = '' try: nextnum = len(msgList) + 1 newmail = pymail.loadmessages(mailserver, mailuser, mailpswd, nextnum) msgList = msgList + newmail except: exc_type, exc_value = sys.exc_info()[:2] # thread exc errInfo = '\n' + str(exc_type) + '\n' + str(exc_value) print 'load exit' threadExitVar = 1 # signal main thread def onLoadMail(): # load all (or new) pop email getpassword() thread.start_new_thread(loadMailThread, ()) busyInfoBoxWait('Retrieving mail') if errInfo: global mailpswd # zap pswd so can reinput mailpswd = None showerror('PyMail', 'Error loading mail\n' + errInfo) fillIndex(msgList) def onViewRawMail(): # view selected message - raw mail text with header lines msgnum = selectedMsg() if not (1 <= msgnum <= len(msgList)): showerror('PyMail', 'No message selected') else: text = msgList[msgnum-1] # put in ScrolledText from ScrolledText import ScrolledText window = Toplevel() window.title('PyMail raw message viewer #' + str(msgnum)) browser = ScrolledText(window) browser.insert('0.0', text) browser.pack(expand=YES, fill=BOTH) def onViewFormatMail(): # view selected message - popup formatted display msgnum = selectedMsg() if not (1 <= msgnum <= len(msgList)): showerror('PyMail', 'No message selected') else: mailtext = msgList[msgnum-1] # put in a TextEditor form textfile = StringIO.StringIO(mailtext) headers = rfc822.Message(textfile) # strips header lines bodytext = textfile.read() # rest is message body editmail('View #%d' % msgnum, headers.get('From', '?'), headers.get('To', '?'), headers.get('Subject', '?'), bodytext, headers.get('Cc', '?')) # see header comments--use objects that retain the # last directory for the next select, instead of the # simple asksaveasfilename(title=xxx) dialog interface saveOneDialog = saveAllDialog = None def myasksaveasfilename_one(): global saveOneDialog if not saveOneDialog: saveOneDialog = SaveAs(title='PyMail Save File') return saveOneDialog.show() def myasksaveasfilename_all(): global saveAllDialog if not saveAllDialog: saveAllDialog = SaveAs(title='PyMail Save All File') return saveAllDialog.show() def onSaveMail(): # save selected message in file if allModeVar.get(): mailfile = myasksaveasfilename_all() if mailfile: try: # maybe this should be a thread for i in range(1, len(msgList)+1): pymail.savemessage(i, mailfile, msgList) except: showerror('PyMail', 'Error during save') else: msgnum = selectedMsg() if not (1 <= msgnum <= len(msgList)): showerror('PyMail', 'No message selected') else: mailfile = myasksaveasfilename_one() if mailfile: try: pymail.savemessage(msgnum, mailfile, msgList) except: showerror('PyMail', 'Error during save') def onDeleteMail(): # mark selected message for deletion on exit global toDelete if allModeVar.get(): toDelete = range(1, len(msgList)+1) else: msgnum = selectedMsg() if not (1 <= msgnum <= len(msgList)): showerror('PyMail', 'No message selected') elif msgnum not in toDelete: toDelete.append(msgnum) # fails if in list twice def sendMailThread(From, To, Cc, Subj, text): # send mail while main thread handles gui events global errInfo, threadExitVar import smtplib, time from mailconfig import smtpservername print 'send start' date = time.ctime(time.time()) Cchdr = (Cc and 'Cc: %s\n' % Cc) or '' hdrs = ('From: %s\nTo: %s\n%sDate: %s\nSubject: %s\n' % (From, To, Cchdr, date, Subj)) hdrs = hdrs + 'X-Mailer: PyMail Version 1.0 (Python)\n' hdrs = hdrs + '\n' # blank line between hdrs,text Ccs = (Cc and string.split(Cc, ';')) or [] # some servers reject [''] Tos = string.split(To, ';') + Ccs # cc: hdr line, and To list Tos = map(string.strip, Tos) # some addrs can have ','s print 'Connecting to mail...', Tos # strip spaces around addrs errInfo = '' failed = {} # smtplib may raise except try: # or return failed Tos dict server = smtplib.SMTP(smtpservername) failed = server.sendmail(From, Tos, hdrs + text) server.quit() except: exc_type, exc_value = sys.exc_info()[:2] # thread exc excinfo = '\n' + str(exc_type) + '\n' + str(exc_value) errInfo = 'Error sending mail\n' + excinfo else: if failed: errInfo = 'Failed recipients:\n' + str(failed) print 'send exit' threadExitVar = 1 # signal main thread def sendMail(From, To, Cc, Subj, text): # send completed email thread.start_new_thread(sendMailThread, (From, To, Cc, Subj, text)) busyInfoBoxWait('Sending mail') if errInfo: showerror('PyMail', errInfo) def onWriteReplyFwdSend(window, editor, hdrs): # mail edit window send button press From, To, Cc, Subj = hdrs sendtext = editor.getAllText() sendMail(From.get(), To.get(), Cc.get(), Subj.get(), sendtext) window.destroy() def editmail(mode, From, To='', Subj='', origtext='', Cc=''): # create a mail edit/view window win = Toplevel() win.title('PyMail - '+ mode) win.iconname('PyMail') viewOnly = (mode[:4] == 'View') # header entry fields frm = Frame(win); frm.pack( side=TOP, fill=X) lfrm = Frame(frm); lfrm.pack(side=LEFT, expand=NO, fill=BOTH) mfrm = Frame(frm); mfrm.pack(side=LEFT, expand=NO, fill=NONE) rfrm = Frame(frm); rfrm.pack(side=RIGHT, expand=YES, fill=BOTH) hdrs = [] for (label, start) in [('From:', From), ('To:', To), ('Cc:', Cc), ('Subj:', Subj)]: lab = Label(mfrm, text=label, justify=LEFT) ent = Entry(rfrm) lab.pack(side=TOP, expand=YES, fill=X) ent.pack(side=TOP, expand=YES, fill=X) ent.insert('0', start) hdrs.append(ent) # send, cancel buttons epatch = [None] sendit = (lambda w=win, e=epatch, h=hdrs: onWriteReplyFwdSend(w, e[0], h)) for (label, callback) in [('Cancel', win.destroy), ('Send', sendit)]: if not (viewOnly and label == 'Send'): b = Button(lfrm, text=label, command=callback) b.config(bg='beige', relief=RIDGE, bd=2) b.pack(side=TOP, expand=YES, fill=BOTH) # body text editor - make,pack last=clip first from TextEditor.textEditor import TextEditorComponentMinimal editor = epatch[0] = TextEditorComponentMinimal(win) editor.pack(side=BOTTOM) if (not viewOnly) and mailconfig.mysignature: # add signature text? origtext = ('\n%s\n' % mailconfig.mysignature) + origtext editor.setAllText(origtext) def onWriteMail(): # compose new email editmail('Write', From=mailconfig.myaddress) def quoteorigtext(msgnum): origtext = msgList[msgnum-1] textfile = StringIO.StringIO(origtext) headers = rfc822.Message(textfile) # strips header lines bodytext = textfile.read() # rest is message body quoted = '\n-----Original Message-----\n' for hdr in ('From', 'To', 'Subject', 'Date'): quoted = quoted + ( '%s: %s\n' % (hdr, headers.get(hdr, '?')) ) quoted = quoted + '\n' + bodytext quoted = '\n' + string.replace(quoted, '\n', '\n> ') return quoted def onReplyMail(): # reply to selected email msgnum = selectedMsg() if not (1 <= msgnum <= len(msgList)): showerror('PyMail', 'No message selected') else: text = quoteorigtext(msgnum) hdrs = rfc822.Message(StringIO.StringIO(msgList[msgnum-1])) To = '%s <%s>' % hdrs.getaddr('From') From = mailconfig.myaddress or '%s <%s>' % hdrs.getaddr('To') Subj = 'Re: ' + hdrs.get('Subject', '(no subject)') editmail('Reply', From, To, Subj, text) def onFwdMail(): # forward selected email msgnum = selectedMsg() if not (1 <= msgnum <= len(msgList)): showerror('PyMail', 'No message selected') else: text = quoteorigtext(msgnum) hdrs = rfc822.Message(StringIO.StringIO(msgList[msgnum-1])) From = mailconfig.myaddress or '%s <%s>' % hdrs.getaddr('To') Subj = 'Fwd: ' + hdrs.get('Subject', '(no subject)') editmail('Forward', From, '', Subj, text) def deleteMailThread(toDelete): # delete mail while main thread handles gui events global errInfo, threadExitVar print 'delete start' try: pymail.deletemessages(mailserver, mailuser, mailpswd, toDelete, 0) except: exc_type, exc_value = sys.exc_info()[:2] errInfo = '\n' + str(exc_type) + '\n' + str(exc_value) else: errInfo = '' print 'delete exit' threadExitVar = 1 # signal main thread def onQuitMail(): # exit mail tool, delete now if askyesno('PyMail', 'Verify Quit?'): if toDelete and askyesno('PyMail', 'Really Delete Mail?'): getpassword() thread.start_new_thread(deleteMailThread, (toDelete,)) busyInfoBoxWait('Deleting mail') if errInfo: showerror('PyMail', 'Error while deleting:\n' + errInfo) else: showinfo('PyMail', 'Mail deleted from server') rootWin.quit() def getpassword(): # prompt for pop password global mailpswd if mailpswd: # getpass.getpass uses stdin, not GUI return # tkSimpleDialog.askstring echos input else: win = Toplevel() win.title('PyMail Prompt') prompt = 'Password for %s on %s?' % (mailuser, mailserver) Label(win, text=prompt).pack(side=LEFT) entvar = StringVar() ent = Entry(win, textvariable=entvar, show='*') ent.pack(side=RIGHT, expand=YES, fill=X) ent.bind('', lambda event, savewin=win: savewin.destroy()) ent.focus_set(); win.grab_set(); win.wait_window() win.update() mailpswd = entvar.get() # ent widget is gone def decorate(rootWin): # window manager stuff for main window rootWin.title('PyMail 1.0') rootWin.iconname('PyMail') rootWin.protocol('WM_DELETE_WINDOW', onQuitMail) def makemainwindow(parent=None): # make the main window global rootWin, listBox, allModeVar if parent: rootWin = Frame(parent) # attach to a parent rootWin.pack(expand=YES, fill=BOTH) else: rootWin = Tk() # assume I'm standalone decorate(rootWin) # add main buttons at bottom frame1 = Frame(rootWin) frame1.pack(side=BOTTOM, fill=X) allModeVar = IntVar() Checkbutton(frame1, text="All", variable=allModeVar).pack(side=RIGHT) actions = [ ('Load', onLoadMail), ('View', onViewFormatMail), ('Save', onSaveMail), ('Del', onDeleteMail), ('Write', onWriteMail), ('Reply', onReplyMail), ('Fwd', onFwdMail), ('Quit', onQuitMail) ] for (title, callback) in actions: Button(frame1, text=title, command=callback).pack(side=LEFT, fill=X) # add main listbox and scrollbar frame2 = Frame(rootWin) vscroll = Scrollbar(frame2) fontsz = (sys.platform[:3] == 'win' and 8) or 10 listBox = Listbox(frame2, bg='white', font=('courier', fontsz)) # crosslink listbox and scrollbar vscroll.config(command=listBox.yview, relief=SUNKEN) listBox.config(yscrollcommand=vscroll.set, relief=SUNKEN, selectmode=SINGLE) listBox.bind('', lambda event: onViewRawMail()) frame2.pack(side=TOP, expand=YES, fill=BOTH) vscroll.pack(side=RIGHT, fill=BOTH) listBox.pack(side=LEFT, expand=YES, fill=BOTH) return rootWin helptext = """ PyMail, version 1.0 February, 2000 Programming Python, 2nd Ed. Click buttons to process email: - Load all or new pop mail - View selected message - Save selected (or all) mails - Delete selected (or all) on exit - Write new mail, send by smtp - Reply to selected mail by smtp - Forward selected mail by smtp - Quit PyMail system and delete Double-clicking on a selected mail in the main listbox lets you view the mail's raw text. Click All to apply Save or Del to all retrieved mails in list. Mail is only removed from pop servers on exit, and only mails deleted with Del are removed. Put ';' between multiple addrs. Change mailconfig.py to reflect your email servers, address, and optional signature. """ def container(): # use attachment to add help button # this is a bit easier with classes root = Tk() title = Button(root, text='PyMail - a Python/Tkinter email client') title.config(bg='steelblue', fg='white', relief=RIDGE) title.config(command=(lambda: showinfo('PyMail', helptext))) title.pack(fill=X) decorate(root) return root if __name__ == '__main__': #run stand-alone or attached #rootWin = makemainwindow() rootWin = makemainwindow(container()) rootWin.mainloop()