File: class/Workbook/Examples/Lecture14/PyMailGui.py

######################################################
# 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('<Return>', 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('<Double-1>', 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()



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