""" ############################################################################### Implementation of View, Write, Reply, Forward windows: one class per kind. Code is factored here for reuse: a Write window is a customized View window, and Reply and Forward are custom Write windows. Windows defined in this file are created by the list windows, in response to user actions. Caveat:'split' pop ups for opening parts/attachments feel nonintuitive. 2.1: this caveat was addressed, by adding quick-access attachment buttons. New in 3.0: platform-neutral grid() for mail headers, not packed col frames. New in 3.0: supports Unicode encodings for main text + text attachments sent. New in 3.0: PyEdit supports arbitrary Unicode for message parts viewed. New in 3.0: supports Unicode/mail encodings for headers in mails sent. TBD: could avoid verifying quits unless text area modified (like PyEdit2.0), but these windows are larger, and would not catch headers already changed. TBD: should Open dialog in write windows be program-wide? (per-window now). ############################################################################### """ from SharedNames import * # program-wide global objects ############################################################################### # message view window - also a superclass of write, reply, forward ############################################################################### class ViewWindow(windows.PopupWindow, mailtools.MailParser): """ a Toplevel, with extra protocol and embedded TextEditor; inherits saveParts,partsList from mailtools.MailParser; mixes in custom subclass logic by direct inheritance here; """ # class attributes modelabel = 'View' # used in window titles from mailconfig import okayToOpenParts # open any attachments at all? from mailconfig import verifyPartOpens # ask before open each part? from mailconfig import maxPartButtons # show up to this many + '...' from mailconfig import skipTextOnHtmlPart # 3.0: just browser, not PyEdit? tempPartDir = 'TempParts' # where 1 selected part saved # all view windows use same dialog: remembers last dir partsDialog = Directory(title=appname + ': Select parts save directory') def __init__(self, headermap, showtext, origmessage=None): """ header map is origmessage, or custom hdr dict for writing; showtext is main text part of the message: parsed or custom; origmessage is parsed email.message.Message for view mail windows """ windows.PopupWindow.__init__(self, appname, self.modelabel) self.origMessage = origmessage self.makeWidgets(headermap, showtext) def makeWidgets(self, headermap, showtext): """ add headers, actions, attachments, text editor 3.0: showtext is assumed to be decoded Unicode str here; it will be encoded on sends and saves as directed/needed; """ actionsframe = self.makeHeaders(headermap) if self.origMessage and self.okayToOpenParts: self.makePartButtons() self.editor = textEditor.TextEditorComponentMinimal(self) myactions = self.actionButtons() for (label, callback) in myactions: b = Button(actionsframe, text=label, command=callback) b.config(bg='beige', relief=RIDGE, bd=2) b.pack(side=TOP, expand=YES, fill=BOTH) # body text, pack last=clip first self.editor.pack(side=BOTTOM) # may be multiple editors self.update() # 3.0: else may be @ line2 #------------------------------------------------------------------ # Jan 2014, 1.5: setAllText failed once in 3.3 (after a decade of # daily use!), leaving thread-busy lock locked. Looks like a Tk # limitation -- it couldn't handle a "speak-no-evil monkey" Unicode # character > 16 bits in the text part of a text+html alternative # message (the html part uses a link instead) -- but this shouldn't # leave the GUI crippled due to an uncaught exception and busy lock # (it could no longer Load, and had to be closed in TaskManager on # Windows). The exception's traceback in the console window: # # File "..\ViewWindows.py", line 75, in makeWidgets # self.editor.setAllText(showtext) # each has own content # File "C:\PP4E\...\Gui\TextEditor\textEditor.py", line 873, in setAllText # self.text.insert(END, text) # or '1.0'; text=bytes or str # File "C:\Python33\lib\tkinter\__init__.py", line 3095, in insert # self.tk.call((self._w, 'insert', index, chars) + args) # ValueError: character U+1f64a is above the range (U+0000-U+FFFF) allowed by Tcl #------------------------------------------------------------------ #self.editor.setAllText(showtext) # each has own content try: self.editor.setAllText(showtext) # each has own content except: showerror(appname, 'Error setting text in view window') printStack(sys.exc_info()) lines = len(showtext.splitlines()) lines = min(lines + 3, mailconfig.viewheight or 20) self.editor.setHeight(lines) # else height=24, width=80 self.editor.setWidth(80) # or from PyEdit textConfig if mailconfig.viewbg: self.editor.setBg(mailconfig.viewbg) # colors, font in mailconfig if mailconfig.viewfg: self.editor.setFg(mailconfig.viewfg) if mailconfig.viewfont: # also via editor Tools menu self.editor.setFont(mailconfig.viewfont) def makeHeaders(self, headermap): """ add header entry fields, return action buttons frame; 3.0: uses grid for platform-neutral layout of label/entry rows; packed row frames with fixed-width labels would work well too; 3.0: decoding of i18n headers (and email names in address headers) is performed here if still required as they are added to the GUI; some may have been decoded already for reply/forward windows that need to use decoded text, but the extra decode here is harmless for these, and is required for other headers and cases such as fetched mail views; always, headers are in decoded form when displayed in the GUI, and will be encoded within mailtools on Sends if they are non-ASCII (see Write); i18n header decoding also occurs in list window mail indexes, and for headers added to quoted mail text; text payloads in the mail body are also decoded for display and encoded for sends elsewhere in the system (list windows, Write); 3.0: creators of edit windows prefill Bcc header with sender email address to be picked up here, as a convenience for common usages if this header is enabled in mailconfig; Reply also now prefills the Cc header with all unique original recipients less From, if enabled; """ top = Frame(self); top.pack (side=TOP, fill=X) left = Frame(top); left.pack (side=LEFT, expand=NO, fill=BOTH) middle = Frame(top); middle.pack(side=LEFT, expand=YES, fill=X) # headers set may be extended in mailconfig (Bcc, others?) self.userHdrs = () showhdrs = ('From', 'To', 'Cc', 'Subject') if hasattr(mailconfig, 'viewheaders') and mailconfig.viewheaders: self.userHdrs = mailconfig.viewheaders showhdrs += self.userHdrs addrhdrs = ('From', 'To', 'Cc', 'Bcc') # 3.0: decode i18n specially self.hdrFields = [] for (i, header) in enumerate(showhdrs): lab = Label(middle, text=header+':', justify=LEFT) ent = Entry(middle) lab.grid(row=i, column=0, sticky=EW) ent.grid(row=i, column=1, sticky=EW) middle.rowconfigure(i, weight=1) hdrvalue = headermap.get(header, '?') # might be empty # 3.0: if encoded, decode per email+mime+unicode if header not in addrhdrs: hdrvalue = self.decodeHeader(hdrvalue) else: hdrvalue = self.decodeAddrHeader(hdrvalue) ent.insert('0', hdrvalue) self.hdrFields.append(ent) # order matters in onSend middle.columnconfigure(1, weight=1) return left def actionButtons(self): # must be method for self return [('Cancel', self.destroy), # close view window silently ('Parts', self.onParts), # multiparts list or the body ('Split', self.onSplit)] def makePartButtons(self): """ add up to N buttons that open attachments/parts when clicked; alternative to Parts/Split (2.1); okay that temp dir is shared by all open messages: part file not saved till later selected and opened; partname=partname is required in lambda in Py2.4; caveat: we could try to skip the main text part; """ def makeButton(parent, text, callback): link = Button(parent, text=text, command=callback, relief=SUNKEN) if mailconfig.partfg: link.config(fg=mailconfig.partfg) if mailconfig.partbg: link.config(bg=mailconfig.partbg) link.pack(side=LEFT, fill=X, expand=YES) parts = Frame(self) parts.pack(side=TOP, expand=NO, fill=X) for (count, partname) in enumerate(self.partsList(self.origMessage)): if count == self.maxPartButtons: makeButton(parts, '...', self.onSplit) break openpart = (lambda partname=partname: self.onOnePart(partname)) makeButton(parts, partname, openpart) def onOnePart(self, partname): """ locate selected part for button and save and open; okay if multiple mails open: resaves each time selected; we could probably just use web browser directly here; caveat: tempPartDir is relative to cwd - poss anywhere; caveat: tempPartDir is never cleaned up: might be large, could use tempfile module (just like the HTML main text part display code in onView of the list window class); """ try: savedir = self.tempPartDir message = self.origMessage (contype, savepath) = self.saveOnePart(savedir, partname, message) except: showerror(appname, 'Error while writing part file') printStack(sys.exc_info()) else: self.openParts([(contype, os.path.abspath(savepath))]) # reuse def onParts(self): """ show message part/attachments in pop-up window; uses same file naming scheme as save on Split; if non-multipart, single part = full body text """ partnames = self.partsList(self.origMessage) msg = '\n'.join(['Message parts:\n'] + partnames) showinfo(appname, msg) def onSplit(self): """ pop up save dir dialog and save all parts/attachments there; if desired, pop up HTML and multimedia parts in web browser, text in TextEditor, and well-known doc types on windows; could show parts in View windows where embedded text editor would provide a save button, but most are not readable text; """ savedir = self.partsDialog.show() # class attr: at prior dir if savedir: # tk dir chooser, not file try: partfiles = self.saveParts(savedir, self.origMessage) except: showerror(appname, 'Error while writing part files') printStack(sys.exc_info()) else: if self.okayToOpenParts: self.openParts(partfiles) def askOpen(self, appname, prompt): if not self.verifyPartOpens: return True else: return askyesno(appname, prompt) # pop-up dialog def openParts(self, partfiles): """ auto-open well known and safe file types, but only if verified by the user in a pop up; other types must be opened manually from save dir; at this point, the named parts have been already MIME-decoded and saved as raw bytes in binary-mode files, but text parts may be in any Unicode encoding; PyEdit needs to know the encoding to decode, webbrowsers may have to guess or be told; caveat: punts for type application/octet-stream even if it has safe filename extension such as .html; caveat: image/audio/video could be opened with the book's playfile.py; could also do that if text viewer fails: would start notepad on Windows via startfile; webbrowser may handle most cases here too, but specific is better; """ def textPartEncoding(fullfilename): """ 3.0: map a text part filename back to charset param in content-type header of part's Message, so we can pass this on to the PyEdit constructor for proper text display; we could return the charset along with content-type from mailtools for text parts, but fewer changes are needed if this is handled as a special case here; part content is saved in binary mode files by mailtools to avoid encoding issues, but here the original part Message is not directly available; we need this mapping step to extract a Unicode encoding name if present; 4E's PyEdit now allows an explicit encoding name for file opens, and resolves encoding on saves; see Chapter 11 for PyEdit policies: it may ask user for an encoding if charset absent or fails; caveat: move to mailtools.mailParser to reuse for in PyMailCGI? """ partname = os.path.basename(fullfilename) for (filename, contype, part) in self.walkNamedParts(self.origMessage): if filename == partname: return part.get_content_charset() # None if not in header assert False, 'Text part not found' # should never happen for (contype, fullfilename) in partfiles: maintype = contype.split('/')[0] # left side extension = os.path.splitext(fullfilename)[1] # not [-4:] basename = os.path.basename(fullfilename) # strip dir # HTML and XML text, web pages, some media if contype in ['text/html', 'text/xml']: browserOpened = False if self.askOpen(appname, 'Open "%s" in browser?' % basename): try: webbrowser.open_new('file://' + fullfilename) browserOpened = True except: showerror(appname, 'Browser failed: trying editor') printStack(sys.exc_info()) # 1.5 if not browserOpened or not self.skipTextOnHtmlPart: try: # try PyEdit to see encoding name and effect encoding = textPartEncoding(fullfilename) textEditor.TextEditorMainPopup(parent=self, winTitle=' - %s email part' % (encoding or '?'), loadFirst=fullfilename, loadEncode=encoding) except: showerror(appname, 'Error opening text viewer') printStack(sys.exc_info()) # 1.5 # text/plain, text/x-python, etc.; 4E: encoding, may fail elif maintype == 'text': if self.askOpen(appname, 'Open text part "%s"?' % basename): try: encoding = textPartEncoding(fullfilename) textEditor.TextEditorMainPopup(parent=self, winTitle=' - %s email part' % (encoding or '?'), loadFirst=fullfilename, loadEncode=encoding) except: # Jan 1014, 1.5: may also fail, but doesn't keep busy lock showerror(appname, 'Error opening text viewer') printStack(sys.exc_info()) # 1.5 # multimedia types: Windows opens mediaplayer, imageviewer, etc. elif maintype in ['image', 'audio', 'video']: if self.askOpen(appname, 'Open media part "%s"?' % basename): try: webbrowser.open_new('file://' + fullfilename) except: showerror(appname, 'Error opening browser') printStack(sys.exc_info()) # 1.5 # common Windows documents: Word, Excel, Adobe, archives, etc. elif (sys.platform[:3] == 'win' and maintype == 'application' and # 3.0: +x types extension in ['.doc', '.docx', '.xls', '.xlsx', # generalize me '.pdf', '.zip', '.tar', '.wmv']): if self.askOpen(appname, 'Open part "%s"?' % basename): os.startfile(fullfilename) else: # punt! msg = 'Cannot open part: "%s"\nOpen manually in: "%s"' msg = msg % (basename, os.path.dirname(fullfilename)) showinfo(appname, msg) ############################################################################### # message edit windows - write, reply, forward ############################################################################### if mailconfig.smtpuser: # user set in mailconfig? MailSenderClass = mailtools.MailSenderAuth # login/password required else: MailSenderClass = mailtools.MailSender class WriteWindow(ViewWindow, MailSenderClass): """ customize view display for composing new mail inherits sendMessage from mailtools.MailSender """ modelabel = 'Write' def __init__(self, headermap, starttext): ViewWindow.__init__(self, headermap, starttext) MailSenderClass.__init__(self) self.attaches = [] # each win has own open dialog self.openDialog = None # dialog remembers last dir def actionButtons(self): return [('Cancel', self.quit), # need method to use self ('Parts', self.onParts), # PopupWindow verifies cancel ('Attach', self.onAttach), ('Send', self.onSend)] # 4E: don't pad: centered def onParts(self): # caveat: deletes not currently supported if not self.attaches: showinfo(appname, 'Nothing attached') else: msg = '\n'.join(['Already attached:\n'] + self.attaches) showinfo(appname, msg) def onAttach(self): """ attach a file to the mail: name added here will be added as a part on Send, inside the mailtools pkg; 4E: could ask Unicode type here instead of on send """ if not self.openDialog: self.openDialog = Open(title=appname + ': Select Attachment File') filename = self.openDialog.show() # remember prior dir if filename: self.attaches.append(filename) # to be opened in send method def resolveUnicodeEncodings(self): """ 3.0/4E: to prepare for send, resolve Unicode encoding for text parts: both main text part, and any text part attachments; the main text part may have had a known encoding if this is a reply or forward, but not for a write, and it may require a different encoding after editing anyhow; smtplib in 3.1 requires that full message text be encodable per ASCII when sent (if it's a str), so it's crucial to get this right here; else fails if reply/fwd to UTF8 text when config=ascii if any non-ascii chars; try user setting and reply but fall back on general UTF8 as a last resort; """ def isTextKind(filename): contype, encoding = mimetypes.guess_type(filename) if contype is None or encoding is not None: # 4E utility return False # no guess, compressed? maintype, subtype = contype.split('/', 1) # check for text/? return maintype == 'text' # resolve many body text encoding bodytextEncoding = mailconfig.mainTextEncoding if bodytextEncoding == None: asknow = askstring('PyMailGUI', 'Enter main text Unicode encoding name') bodytextEncoding = asknow or 'latin-1' # or sys.getdefaultencoding()? # last chance: use utf-8 if can't encode per prior selections if bodytextEncoding != 'utf-8': try: bodytext = self.editor.getAllText() bodytext.encode(bodytextEncoding) except (UnicodeError, LookupError): # lookup: bad encoding name bodytextEncoding = 'utf-8' # general code point scheme # resolve any text part attachment encodings attachesEncodings = [] config = mailconfig.attachmentTextEncoding for filename in self.attaches: if not isTextKind(filename): attachesEncodings.append(None) # skip non-text: don't ask elif config != None: attachesEncodings.append(config) # for all text parts if set else: prompt = 'Enter Unicode encoding name for %' % filename asknow = askstring('PyMailGUI', prompt) attachesEncodings.append(asknow or 'latin-1') # last chance: use utf-8 if can't decode per prior selections choice = attachesEncodings[-1] if choice != None and choice != 'utf-8': try: attachbytes = open(filename, 'rb').read() attachbytes.decode(choice) except (UnicodeError, LookupError, IOError): attachesEncodings[-1] = 'utf-8' return bodytextEncoding, attachesEncodings def onSend(self): """ threaded: mail edit window Send button press; may overlap with any other thread, disables none but quit; Exit,Fail run by threadChecker via queue in after callback; caveat: no progress here, because send mail call is atomic; assumes multiple recipient addrs are separated with ','; mailtools module handles encodings, attachments, Date, etc; mailtools module also saves sent message text in a local file 3.0: now fully parses To,Cc,Bcc (in mailtools) instead of splitting on the separator naively; could also use multiline input widgets instead of simple entry; Bcc added to envelope, not headers; 3.0: Unicode encodings of text parts is resolved here, because it may require GUI prompts; mailtools performs the actual encoding for parts as needed and requested; 3.0: i18n headers are already decoded in the GUI fields here; encoding of any non-ASCII i18n headers is performed in mailtools, not here, because no GUI interaction is required; """ # resolve Unicode encoding for text parts; bodytextEncoding, attachesEncodings = self.resolveUnicodeEncodings() # get components from GUI; 3.0: i18n headers are decoded fieldvalues = [entry.get() for entry in self.hdrFields] From, To, Cc, Subj = fieldvalues[:4] extraHdrs = [('Cc', Cc), ('X-Mailer', appname + ' (Python)')] extraHdrs += list(zip(self.userHdrs, fieldvalues[4:])) bodytext = self.editor.getAllText() # split multiple recipient lists on ',', fix empty fields Tos = self.splitAddresses(To) for (ix, (name, value)) in enumerate(extraHdrs): if value: # ignored if '' if value == '?': # ? not replaced extraHdrs[ix] = (name, '') elif name.lower() in ['cc', 'bcc']: # split on ',' extraHdrs[ix] = (name, self.splitAddresses(value)) # withdraw to disallow send during send # caveat: might not be foolproof - user may deiconify if icon visible self.withdraw() self.getPassword() # if needed; don't run pop up in send thread! popup = popuputil.BusyBoxNowait(appname, 'Sending message') sendingBusy.incr() threadtools.startThread( action = self.sendMessage, args = (From, Tos, Subj, extraHdrs, bodytext, self.attaches, saveMailSeparator, bodytextEncoding, attachesEncodings), context = (popup,), onExit = self.onSendExit, onFail = self.onSendFail) def onSendExit(self, popup): """ erase wait window, erase view window, decr send count; sendMessage call auto saves sent message in local file; can't use window.addSavedMails: mail text unavailable; """ popup.quit() self.destroy() sendingBusy.decr() # poss \ when opened, / in mailconfig sentname = os.path.abspath(mailconfig.sentmailfile) # also expands '.' if sentname in openSaveFiles.keys(): # sent file open? window = openSaveFiles[sentname] # update list,raise window.loadMailFileThread() def onSendFail(self, exc_info, popup): # pop-up error, keep msg window to save or retry, redraw actions frame popup.quit() self.deiconify() self.lift() showerror(appname, 'Send failed: \n%s\n%s' % exc_info[:2]) printStack(exc_info) MailSenderClass.smtpPassword = None # try again; 3.0/4E: not self sendingBusy.decr() def askSmtpPassword(self): """ get password if needed from GUI here, in main thread; caveat: may try this again in thread if no input first time, so goes into a loop until input is provided; see pop paswd input logic for a nonlooping alternative """ password = '' while not password: prompt = ('Password for %s on %s?' % (self.smtpUser, self.smtpServerName)) password = popuputil.askPasswordWindow(appname, prompt) return password class ReplyWindow(WriteWindow): """ customize write display for replying text and headers set up by list window """ modelabel = 'Reply' class ForwardWindow(WriteWindow): """ customize reply display for forwarding text and headers set up by list window """ modelabel = 'Forward'