I thought it would be a good idea to write a sample application in wxPython to show how to put all the pieces together and make something useful. At my day job, I created a little program to send emails because we had a lot of users that missed the mailto functionality that we lost when we switched from Exchange/Outlook to Zimbra. It should be noted that this is a Windows only application currently, but it shouldn't be too hard to make it more OS-agnostic.
I'll split this article into three pieces: First is creating the interface; second is setting up the data handling and third will be creating a Windows executable and connecting it to the mailto handler.When we're done, the GUI will look something like this:
To follow along, you'll need the following:
Let's go over the code below. As you can see, I am basing this application on the wx.Frame object and an instance of wx.PySimpleApp to make the application run.
import os import sys import urllib import wx import mail_ico class SendMailWx(wx.Frame): def __init__(self): wx.Frame.__init__(self, None, -1, 'New Email Message (Plain Text)', size=(600,400)) self.panel = wx.Panel(self, wx.ID_ANY) # set your email address here self.email = 'myEmail@email.com' self.filepaths =  self.currentDir = os.path.abspath(os.path.dirname(sys.argv)) self.createMenu() self.createToolbar() self.createWidgets() try: print sys.argv self.parseURL(sys.argv) except Exception, e: print 'Unable to execute parseURL...' print e self.layoutWidgets() self.attachTxt.Hide() self.editAttachBtn.Hide() def createMenu(self): menubar = wx.MenuBar() fileMenu = wx.Menu() send_menu_item = fileMenu.Append(wx.NewId(), '&Send', 'Sends the email') close_menu_item = fileMenu.Append(wx.NewId(), '&Close', 'Closes the window') menubar.Append(fileMenu, '&File') self.SetMenuBar(menubar) # bind events to the menu items self.Bind(wx.EVT_MENU, self.onSend, send_menu_item) self.Bind(wx.EVT_MENU, self.onClose, close_menu_item) def createToolbar(self): toolbar = self.CreateToolBar(wx.TB_3DBUTTONS|wx.TB_TEXT) toolbar.SetToolBitmapSize((31,31)) bmp = mail_ico.getBitmap() sendTool = toolbar.AddSimpleTool(-1, bmp, 'Send', 'Sends Email') self.Bind(wx.EVT_MENU, self.onSend, sendTool) toolbar.Realize() def createWidgets(self): p = self.panel font = wx.Font(10, wx.SWISS, wx.NORMAL, wx.BOLD) self.fromLbl = wx.StaticText(p, wx.ID_ANY, 'From', size=(60,-1)) self.fromTxt = wx.TextCtrl(p, wx.ID_ANY, self.email) self.toLbl = wx.StaticText(p, wx.ID_ANY, 'To:', size=(60,-1)) self.toTxt = wx.TextCtrl(p, wx.ID_ANY, '') self.subjectLbl = wx.StaticText(p, wx.ID_ANY, ' Subject:', size=(60,-1)) self.subjectTxt = wx.TextCtrl(p, wx.ID_ANY, '') self.attachBtn = wx.Button(p, wx.ID_ANY, 'Attachments') self.attachTxt = wx.TextCtrl(p, wx.ID_ANY, '', style=wx.TE_MULTILINE) self.attachTxt.Disable() self.editAttachBtn = wx.Button(p, wx.ID_ANY, 'Edit Attachments') self.messageTxt = wx.TextCtrl(p, wx.ID_ANY, '', style=wx.TE_MULTILINE) self.Bind(wx.EVT_BUTTON, self.onAttach, self.attachBtn) self.Bind(wx.EVT_BUTTON, self.onAttachEdit, self.editAttachBtn) self.fromLbl.SetFont(font) self.toLbl.SetFont(font) self.subjectLbl.SetFont(font) def layoutWidgets(self): mainSizer = wx.BoxSizer(wx.VERTICAL) fromSizer = wx.BoxSizer(wx.HORIZONTAL) toSizer = wx.BoxSizer(wx.HORIZONTAL) subjSizer = wx.BoxSizer(wx.HORIZONTAL) attachSizer = wx.BoxSizer(wx.HORIZONTAL) fromSizer.Add(self.fromLbl, 0) fromSizer.Add(self.fromTxt, 1, wx.EXPAND) toSizer.Add(self.toLbl, 0) toSizer.Add(self.toTxt, 1, wx.EXPAND) subjSizer.Add(self.subjectLbl, 0) subjSizer.Add(self.subjectTxt, 1, wx.EXPAND) attachSizer.Add(self.attachBtn, 0, wx.ALL, 5) attachSizer.Add(self.attachTxt, 1, wx.ALL|wx.EXPAND, 5) attachSizer.Add(self.editAttachBtn, 0, wx.ALL, 5) mainSizer.Add(fromSizer, 0, wx.ALL|wx.EXPAND, 5) mainSizer.Add(toSizer, 0, wx.ALL|wx.EXPAND, 5) mainSizer.Add(subjSizer, 0, wx.ALL|wx.EXPAND, 5) mainSizer.Add(attachSizer, 0, wx.ALL|wx.EXPAND, 5) mainSizer.Add(self.messageTxt, 1, wx.ALL|wx.EXPAND, 5) self.panel.SetSizer(mainSizer) self.panel.Layout() def parseURL(self, url): ''' Parse the URL passed from the mailto link ''' sections = 1 mailto_string = url.split(':') if '?' in mailto_string: sections = mailto_string.split('?') else: address = mailto_string if len(sections) > 1: address = sections new_sections = urllib.unquote(sections).split('&') for item in new_sections: if 'subject' in item.lower(): Subject = item.split('=') self.subjectTxt.SetValue(Subject) if 'body' in item.lower(): Body = item.split('=') self.messageTxt.SetValue(Body) self.toTxt.SetValue(address) def onAttach(self, event): ''' Displays a File Dialog to allow the user to choose a file and then attach it to the email. ''' print "in onAttach method..." def onAttachEdit(self, event): ''' Allow the editing of the attached files list ''' print "in onAttachEdit method..." def onSend(self, event): ''' Send the email using the filled out textboxes. Warn the user if they forget to fill part of it out. ''' print "in onSend event handler..." def onClose(self, event): self.Close() ####################### # Start program if __name__ == '__main__': app = wx.PySimpleApp() frame = SendMailWx() frame.Show() app.MainLoop()
I've already explained how to create toolbars, menus and sizers in previous posts, so I'm going to focus on the new stuff here. I import the urllib module to help in parsing the data sent from the mailto link on a web page. I currently support the To, Subject and Body fields of the mailto protocol. The respective textboxes are set depending on the number of sections that are passed into the parseURL() method. You could easily extend this is need be. I also grab the directory where the script is running from by using this line of code:
self.currentDir = os.path.abspath(os.path.dirname(sys.argv))
Finally, there are three event handler stubs: "onAttach", "onAttachEdit", and "onSend". Let's go ahead and flesh these out a bit.
The first method, onAttach(), allows the user to attach files to their email message. I use the wx.FileDialog to get the user's choice. Here is where the "filepaths" property comes in. I also call the new method,getFileSize, which will calculate the file's size. See the code below:
def onAttach(self, event): ''' Displays a File Dialog to allow the user to choose a file and then attach it to the email. ''' attachments = self.attachTxt.GetLabel() filepath = '' # create a file dialog wildcard = "All files (*.*)|*.*" dialog = wx.FileDialog(None, 'Choose a file', self.currentDir, '', wildcard, wx.OPEN) # if the user presses OK, get the path if dialog.ShowModal() == wx.ID_OK: self.attachTxt.Show() self.editAttachBtn.Show() filepath = dialog.GetPath() print filepath # Change the current directory to reflect the last dir opened os.chdir(os.path.dirname(filepath)) self.currentDir = os.getcwd() # add the user's file to the filepath list if filepath != '': self.filepaths.append(filepath) # get file size fSize = self.getFileSize(filepath) # modify the attachment's label based on it's current contents if attachments == '': attachments = '%s (%s)' % (os.path.basename(filepath), fSize) else: temp = '%s (%s)' % (os.path.basename(filepath), fSize) attachments = attachments + '; ' + temp self.attachTxt.SetLabel(attachments) dialog.Destroy() def getFileSize(self, f): ''' Get the file's approx. size ''' fSize = os.stat(f).st_size if fSize >= 1073741824: # gigabyte fSize = int(math.ceil(fSize/1073741824.0)) size = '%s GB' % fSize elif fSize >= 1048576: # megabyte fSize = int(math.ceil(fSize/1048576.0)) size = '%s MB' % fSize elif fSize >= 1024: # kilobyte fSize = int(math.ceil(fSize/1024.0)) size = '%s KB' % fSize else: size = '%s bytes' % fSize return size
You'll also notice that I save the last directory the user goes into. I still come across programs that don't do this or don't do it consistently. Hopefully my implementation will work in the majority of cases. The getFileSize() method is supposed to calculate the size of the attached file. This only displays the nearest size and doesn't show fractions. Other than that, I think it's pretty self-explanatory.
The onAttachEdit() method is pretty similar, except that it calls a custom dialog to allow the user to edit what files are included in case they chose one erroneously.
def onAttachEdit(self, event): ''' Allow the editing of the attached files list ''' print 'in onAttachEdit...' attachments = '' dialog = EditDialog(self.filepaths) dialog.ShowModal() self.filepaths = dialog.filepaths print 'Edited paths:\n', self.filepaths dialog.Destroy() if self.filepaths == : # hide the attachment controls self.attachTxt.Hide() self.editAttachBtn.Hide() else: for path in self.filepaths: # get file size fSize = self.getFileSize(path) # Edit the attachments listed if attachments == '': attachments = '%s (%s)' % (os.path.basename(path), fSize) else: temp = '%s (%s)' % (os.path.basename(path), fSize) attachments = attachments + '; ' + temp self.attachTxt.SetLabel(attachments) class EditDialog(wx.Dialog): def __init__(self, filepaths): wx.Dialog.__init__(self, None, -1, 'Edit Attachments', size=(190,150)) self.filepaths = filepaths instructions = 'Check the items below that you no longer wish to attach to the email' lbl = wx.StaticText(self, wx.ID_ANY, instructions) deleteBtn = wx.Button(self, wx.ID_ANY, 'Delete Items') cancelBtn = wx.Button(self, wx.ID_ANY, 'Cancel') self.Bind(wx.EVT_BUTTON, self.onDelete, deleteBtn) self.Bind(wx.EVT_BUTTON, self.onCancel, cancelBtn) mainSizer = wx.BoxSizer(wx.VERTICAL) btnSizer = wx.BoxSizer(wx.HORIZONTAL) mainSizer.Add(lbl, 0, wx.ALL, 5) self.chkList = wx.CheckListBox(self, wx.ID_ANY, choices=self.filepaths) mainSizer.Add(self.chkList, 0, wx.ALL, 5) btnSizer.Add(deleteBtn, 0, wx.ALL|wx.CENTER, 5) btnSizer.Add(cancelBtn, 0, wx.ALL|wx.CENTER, 5) mainSizer.Add(btnSizer, 0, wx.ALL|wx.CENTER, 5) self.SetSizer(mainSizer) self.Fit() self.Layout() def onCancel(self, event): self.Close() def onDelete(self, event): print 'in onDelete' numberOfPaths = len(self.filepaths) for item in range(numberOfPaths): val = self.chkList.IsChecked(item) if val == True: path = self.chkList.GetString(item) print path for i in range(len(self.filepaths)-1,-1,-1): if path in self.filepaths[i]: del self.filepaths[i] print 'new list => ', self.filepaths self.Close()
The main thing to notice in the code above is that the EditDialog is sub-classing wx.Dialog. The reason I chose this over a wx.Frame is because I wanted my dialog to be non-modal and I think using the wx.Dialog class makes the most sense for this. Probably the most interesting part of this class is my onDelete method, in which I loop over the paths backwards. I do this so I can delete the items in any order without comprising the integrity of the list. For example, if I had deleted element 2 repeatedly, I would probably end up deleting an element I didn't mean to.
My last method is the onSend() one. I think it is probably the most complex and the one that will need refactoring the most. In this implementation, all the SMTP elements are hard coded. Let's take a look and see how it works:
def OnSend(self, event): ''' Send the email using the filled out textboxes. Warn the user if they forget to fill part of it out. ''' From = self.fromTxt.GetValue() To = self.toTxt.GetValue() Subject = self.subjectTxt.GetValue() text = self.messageTxt.GetValue() colon = To.find(';') period = To.find(',') if colon != -1: temp = To.split(';') To = self.sendStrip(temp) #';'.join(temp) elif period != -1: temp = To.split(',') To = self.sendStrip(temp) #';'.join(temp) else: pass if To == '': print 'add an address to the "To" field!' dlg = wx.MessageDialog(None, 'Please add an address to the "To" field and try again', 'Error', wx.OK|wx.ICON_EXCLAMATION) dlg.ShowModal() dlg.Destroy() elif Subject == '': dlg = wx.MessageDialog(None, 'Please add a "Subject" and try again', 'Error', wx.OK|wx.ICON_EXCLAMATION) dlg.ShowModal() dlg.Destroy() elif From == '': lg = wx.MessageDialog(None, 'Please add an address to the "From" field and try again', 'Error', wx.OK|wx.ICON_EXCLAMATION) dlg.ShowModal() dlg.Destroy() else: msg = MIMEMultipart() msg['From'] = From msg['To'] = To msg['Subject'] = Subject msg['Date'] = formatdate(localtime=True) msg.attach( MIMEText(text) ) if self.filepaths != : print 'attaching file(s)...' for path in self.filepaths: part = MIMEBase('application', "octet-stream") part.set_payload( open(path,"rb").read() ) Encoders.encode_base64(part) part.add_header('Content-Disposition', 'attachment; filename="%s"' % os.path.basename(path)) msg.attach(part) # edit this to match your mail server (i.e. mail.myserver.com) server = smtplib.SMTP('mail.myserver.org') # open login dialog dlg = LoginDlg(server) res = dlg.ShowModal() if dlg.loggedIn: dlg.Destroy() # destroy the dialog try: failed = server.sendmail(From, To, msg.as_string()) server.quit() self.Close() # close the program except Exception, e: print 'Error - send failed!' print e else: if failed: print 'Failed:', failed else: dlg.Destroy()
Most of this you've seen before, so I'm only going to talk about the email module calls. Tha main part to noice is that to create an email message with attachments, you'll want to use the MIMEMultipart czll. I used it to add the "From", "To", "Subject" and "Date" fields. To attach files, you'll need to use MIMEBase. Finally, to send the email, you'll need to set the SMTP server, which I did using the smptlib library and login, which is what the LoginDlg is for. I'll go over that next, but before I do I would like to recommend reading both module's respective documentation for full details as they much more functionality that I do not use in this example.
I noticed that my code didn't work outside my organization and it took me a while to figure out why. It turns out that when I'm logged in at work, I'm also logged into our webmail system, so I don't need to authenticate with it. When I'm outside, I do. Since this is actually pretty normal procedure for SMTP servers, I included a fairly simple login dialog. Let's take a look at the code:
class LoginDlg(wx.Dialog): def __init__(self, server): wx.Dialog.__init__(self, None, -1, 'Login', size=(190,150)) self.server = server self.loggedIn = False # widgets userLbl = wx.StaticText(self, wx.ID_ANY, 'Username:', size=(50, -1)) self.userTxt = wx.TextCtrl(self, wx.ID_ANY, '') passwordLbl = wx.StaticText(self, wx.ID_ANY, 'Password:', size=(50, -1)) self.passwordTxt = wx.TextCtrl(self, wx.ID_ANY, '', size=(150, -1), style=wx.TE_PROCESS_ENTER|wx.TE_PASSWORD) loginBtn = wx.Button(self, wx.ID_YES, 'Login') cancelBtn = wx.Button(self, wx.ID_ANY, 'Cancel') self.Bind(wx.EVT_BUTTON, self.OnLogin, loginBtn) self.Bind(wx.EVT_TEXT_ENTER, self.OnTextEnter, self.passwordTxt) self.Bind(wx.EVT_BUTTON, self.OnClose, cancelBtn) # sizer / layout userSizer = wx.BoxSizer(wx.HORIZONTAL) passwordSizer = wx.BoxSizer(wx.HORIZONTAL) btnSizer = wx.BoxSizer(wx.HORIZONTAL) mainSizer = wx.BoxSizer(wx.VERTICAL) userSizer.Add(userLbl, 0, wx.ALL, 5) userSizer.Add(self.userTxt, 0, wx.ALL, 5) passwordSizer.Add(passwordLbl, 0, wx.LEFT|wx.RIGHT, 5) passwordSizer.Add(self.passwordTxt, 0, wx.LEFT, 5) btnSizer.Add(loginBtn, 0, wx.ALL, 5) btnSizer.Add(cancelBtn, 0, wx.ALL, 5) mainSizer.Add(userSizer, 0, wx.ALL, 0) mainSizer.Add(passwordSizer, 0, wx.ALL, 0) mainSizer.Add(btnSizer, 0, wx.ALL|wx.CENTER, 5) self.SetSizer(mainSizer) self.Fit() self.Layout() def OnTextEnter(self, event): ''' When enter is pressed, login method is run. ''' self.OnLogin('event') def OnLogin(self, event): ''' When the "Login" button is pressed, the credentials are authenticated. If correct, the email will attempt to be sent. If incorrect, the user will be notified. ''' try: user = self.userTxt.GetValue() pw = self.passwordTxt.GetValue() res = self.server.login(user, pw) self.loggedIn = True self.OnClose('') except: message = 'Your username or password is incorrect. Please try again.' dlg = wx.MessageDialog(None, message, 'Login Error', wx.OK|wx.ICON_EXCLAMATION) dlg.ShowModal() dlg.Destroy() def OnClose(self, event): self.Close()
For the most part, we've seen this before. The primary part to notice is that I have added two styles to my password TextCtrl: wx.TE_PROCESS_ENTER and wx.TE_PASSWORD. The first will allow you to press enter to login rather than pressing the Login button explicitly. The TE_PASSWORD style obscures the text typed into the TextCtrl with black circles or asterisks.
Also you should note that your username may include your email's url too. For example, rather than just username, it may be email@example.com. Fortunately, if the login is incorrect, the program will throw an error and display a dialog letting the user know.
The final thing to do on Windows is to set it to use this script when the user clicks on a mailto link. To do this, you'll need to mess with the Windows Registry. Before you do anything with the registry, be sure to back it up as there's always a chance that you may break something, including the OS.
To begin, go to Start, Run and type regedit. Now navigate to the following location:
Just expand the tree on the right to navigate the tree. One there, you'll need to edit the (Default) key on the right. It should be of type REG_SZ. Just double-click it to edit it. Here's what you'll want to put in there:
cmd /C "SET PYTHONHOME=c:\path\to\Python24&&c:\path\to\python24\python.exe c:\path\to\wxPyMail.py %1"
Basically, this tells Windows to set Python's home directory and the path to the python.exe as an environmental variable, which I think is only temporary. It then passes the wxPyMail.py script we created to the python.exe specified. The "%1" are the arguments passed by the mailto link. Once you hit the OK button, it's saved and should just start working.
Now you know how to make a fully functional application with wxPython. In my next post, I will show how to package it up as an executable so you can distribute it.
Some possible improvements that you could add:
Download the Source:
Copyright © 2022 Mouse Vs Python | Powered by Pythonlibrary