File: pymailgui-products/unzipped/PyMailGui-PP4E/ViewWindows.py
""" ############################################################################### 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. UPDATE => [4.0] now does verify quits only if text area modified+unsaved. TBD: should Open dialog in write windows be program-wide? (per-window now). ############################################################################### """ from SharedNames import * # program-wide global objects import textConfig # don't veto PyEdit color cycling [4.0] ############################################################################### # 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 splitOpensAll # [4.0] Split opens after saves? from mailconfig import skipTextOnHtmlPart # 3.0: just browser, not PyEdit? from mailconfig import clickablePartTypes # [4.0] application exts to open tempPartDir = tempPartDir # where each selected part saved # all view windows use same dialog: remembers last dir dlgtitle = appname + ': Select parts save directory' if sys.platform.startswith('darwin'): # [4.0] Mac: use message (title ignored), no slidedowns (multiwindow) dlgkargs = dict(message=dlgtitle) else: # Windows+Linux: normal popup args dlgkargs = dict(title=dlgtitle) partsDialog = Directory(**dlgkargs) 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; [4.0] withdraw while buiding to eliminate most flashes on View window opens; this was later partly obsoleted by dropping the Text.update() workaround for see() - the cause of most flashing (see setAllText()); still, an empty box appeared on Windows for the first ViewWindow, which is avoided by hiding/unhiding here; this seems unnecessary but harmless on the Mac test machine used; ListWindow now do the same, though their flash is less and rarer; """ windows.PopupWindow.__init__(self, appname, self.modelabel, withdraw=True) self.origMessage = origmessage self.makeWidgets(headermap, showtext) # [4.0] reset root's menu lost on PyEdit popups (see module: Tk bug?) from PP4E.Gui.Tools import guimaker guimaker.fixAppleMenuBarChild(self) # all built: it's show time (minimizes flashes) self.deiconify() def quit(self): """ [4.0] replace window class's quit() to check for changes in embedded PyEdit text window; prompt only if text has changed, and close View container only if the user verifies closure; this now applies to all view types: View, Write, Reply, Forward; replaces the former prompt policy: [View=(Cancel=never, X=always), all others=always], which was coded by action button callbacks; why onNo: can't tell if window was destroyed by PopupWindow; caveat: this doesn't check changes in smaller header fields, but neither does the all-windows check on ListWindow app quit; """ if self.editor.isModified(): notice = "This window's text has changed.\n\n" windows.PopupWindow.quit(self, notice, # close window if verified onNo=self.refocusText) # else refocus on its text else: self.destroy() # no changes: silent close def refocusText(self): """ [4.0] call after standard dialog closes to restore focus on the text view/edit component, else user must click on Mac (at least under ActiveState's possibly-buggy Tk 8.5); see PyEdit for more on this requirement: it spans all programs; """ self.editor.text.focus_force() def makeWidgets(self, headermap, showtext): """ add headers, actions, attachments, embedded 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: # [4.0] Button doesn't do bg color on Mac OS X - use a Label if sys.platform.startswith('darwin'): b = Label(actionsframe, text=label) b.bind('<Button-1>', lambda event, savecb=callback: savecb()) else: b = Button(actionsframe, text=label, command=callback) b.config(bg='beige', relief=RIDGE, bd=2) b.pack(side=TOP, expand=YES, fill=BOTH) # Dec2015: config action button colors if not None if mailconfig.ctrlfg: b.config(fg=mailconfig.ctrlfg) if mailconfig.ctrlbg: b.config(bg=mailconfig.ctrlbg) # body text, pack last=clip first self.editor.pack(side=BOTTOM) # may be multiple editors #------------------------------------------------------------------------------ # [4.0] this Tk bug is still around in some installs (see() opens with # line 2 at top of window for newly-packed text widgets), but a PyEdit # workaround in setAllText() resolved this and made the update() here # moot; update caused flashes on window opens, and broke some resizes; #------------------------------------------------------------------------------ #self.update() # 3.0: else may be @ line2 #------------------------------------------------------------------------------ # Jan2014, 1.5: setAllText failed once in 3.3 (after a decade of # daily use!), leaving a thread-busy lock locked. This is 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 raised in self.tk.call(): # ValueError: character U+1f64a is above the range (U+0000-U+FFFF) allowed by Tcl # #try: # self.editor.setAllText(showtext) # each has own content #except: # showerror(appname, 'Error setting text in view window') # printStack(sys.exc_info()) # self.editor.setAllText('**bad unicode char in body text**') #------------------------------------------------------------------------------ # [4.0] this has popped up in too many places to handle individually; # replace chars outside the BMP's range with Unicode replacement character; # caveat: PyEdit eventually evolved to do this on its own (redundant here); #------------------------------------------------------------------------------ self.editor.setAllText(fixTkBMP(showtext)) # each editor has own content self.editor.clearModified() # [4.0] not yet changed (saves) self.editor.clearUndoStack() # [4.0] don't undo initial text # [4.0] there is a bizarre bug in Mac Tk, where an initial height of # 35 makes it impossible to resize to less than 32 or 31; so use 34... lines = len(showtext.splitlines()) lines = min(lines, mailconfig.viewheight or 20) lines = max(lines, mailconfig.viewheightmin or 10) self.editor.setHeight(lines) # else height=24, width=80 self.editor.setWidth(80) # or from PyEdit textConfig # [4.0] don't veto PyEdit 3.0's color cycling if enabled if not getattr(textConfig, 'colorCycling', False): if mailconfig.viewbg: # colors, font in mailconfig self.editor.setBg(mailconfig.viewbg) if mailconfig.viewfg: self.editor.setFg(mailconfig.viewfg) if mailconfig.viewfont: # also via editor View 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 # [4.1] user config may add this to override From in replies addrhdrs += ('Reply-To',) 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) # [4.0] allow header display/edit font user configs if hasattr(mailconfig, 'headerfont') and mailconfig.headerfont: ent.config(font=mailconfig.headerfont) 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) #-------------------------------------------------------------------------- # Jun2015, 1.5: bad unicode chars here too (and list hdrs, view body); # this may eventually require sanitizing all text, barring a fix in Tk; # _tkinter.TclError: character U+1f48a is above the range (U+0000-U+FFFF) allowed # #try: # ent.insert('0', hdrvalue) #except: # showerror(appname, 'Error setting text in view window') # printStack(sys.exc_info()) # ent.insert('0', '**bad unicode char in view header**') #-------------------------------------------------------------------------- # [4.0] yep (this has popped up in too many places to handle individually); # replace chars outside the BMP's range with Unicode replacement character; #-------------------------------------------------------------------------- ent.insert('0', fixTkBMP(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.quit), # [4.0] destroy=>quit: prompt ('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): # [4.0] replace chars outside BMP range with Unicode replacement char text = fixTkBMP(text) # else emojis leave part buttons undrawn! # [4.0] Button doesn't do bg color on Mac OS X - use a Label instead if sys.platform.startswith('darwin'): link = Label(parent, text=text, relief=SUNKEN) link.bind('<Button-1>', lambda event: callback()) else: 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); [4.0] update: tempPartDir (./TempParts) now _is_ pruned of old files everytime PyMailGui starts - see PyMailGui.py for the code, and mailconfigs.py for the #days setting; """ try: savedir = self.tempPartDir message = self.origMessage (contype, savepath) = self.saveOnePart(savedir, partname, message) except: showerror(appname, 'Error while writing part file') self.refocusText() # [4.0] Mac printStack(sys.exc_info()) else: self.openParts([(contype, os.path.abspath(savepath))]) # reuse me 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, fixTkBMP(msg)) self.refocusText() # [4.0] else Mac needs click 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/mac; could show parts in View windows where embedded text editor would provide a save button, but most are not readable text; [4.0] allow config file to suppress auto-opens after saves: show info dialog if saves disabled; this is in addition to okayToOpenParts (global) and verifyPartOpens (always ask); TBD: open file explorer instead of info dialog, or always open info dialog (it gets lost in mix if many parts opened)? """ savedir = self.partsDialog.show() # class attr: at prior dir self.refocusText() # [4.0] else Mac needs click if savedir: # tk dir chooser, not file try: partfiles = self.saveParts(savedir, self.origMessage) except: showerror(appname, 'Error while writing part files') self.refocusText() # [4.0] else Mac needs click printStack(sys.exc_info()) else: if self.okayToOpenParts and self.splitOpensAll: self.openParts(partfiles) else: msg = 'See saved parts in:\n%s.' % savedir showinfo(appname, fixTkBMP(msg)) self.refocusText() # [4.0] else Mac needs click def askOpen(self, appname, prompt): if not self.verifyPartOpens: return True else: reply = askyesno(appname, prompt) # pop-up dialog self.refocusText() # [4.0] else Mac needs click return reply def openParts(self, partfiles): """ auto open well-known and safe file types, but only if verified by the user in a popup (or config file setting); other types must be opened manually from TempParts save dir or Split selected 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, look ahead for <meta>, or be told; caveats and tbds: 1) punts for MIME type application/octet-stream even if part has a safe filename extension such as .html or .jpg (should it? - this may indicate a deliberate miscoding or spam, especially in email) 2) punts for document types that indicate embedded macros, such as .xlsm, and .xlsb (should it? - users can enable/disable macros in programs as desired, but macros are easy to miss in email parts) 3) image, audio, and video parts could be opened with the book's playfile.py (which could also be used if text viewer fails - it would start notepad or other on Windows via startfile as is) 4) webbrowser may handle most cases here alone, but specific is generally better where possible [4.0] try to open doc types on Mac OS X via the nonblocking call os.system('open filename'); this opens as though clicked in Finder, and is a Mac equivalent to os.startfile() on Windows (which uses Windows per-extension associations, and works like a dos 'start'); python's webbrowser module runs AppleScript code on Macs which has a similar effect; running 'open -n filename' forces a new instance on the current desktop, but can clutter the Dock over time, and so isn't used here as currently coded; could use PP4E.launchmodes.Start instead of the opening code here, but that lib is currently in flux; [4.0] try to open doc types on Linux via a nonblocking 'xdg-open'; this may not work on all Linux (TDB), but does on some; Linux file explorer clicks tend to be more involved (like much on Linux); [4.0] when punting, try to open a file explorer window on the save folder for convenience, instead of just a popup with the folder's name; opening an explorer is the same as opening a doc; [4.0] don't bother with self.refocusText() for the rare error case popups here that haven't been seen in the wild in very many years; """ 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 <meta> 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 # [4.0] texteditor changed to use fixTkBMP encoding = textPartEncoding(fullfilename) textEditor.TextEditorMainPopup( parent=None, # [4.0] don't close with 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: use encoding, and may fail elif maintype == 'text': if self.askOpen(appname, 'Open text part "%s"?' % basename): try: # [4.0] texteditor changed to use fixTkBMP encoding = textPartEncoding(fullfilename) textEditor.TextEditorMainPopup( parent=None, # [4.0] don't close with self winTitle='%s email part' % (encoding or '?'), loadFirst=fullfilename, loadEncode=encoding) except: # Jan2014, 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 => mediaplayer, photoviewer,...; Mac => preview 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 # documents on Windows/Mac/Linux: Word, Excel, Adobe, zips,... (non-macro!) elif (sys.platform.startswith(('win', 'darwin', 'linux')) and maintype == 'application' and # [4.0] Mac too extension.lower() in self.clickablePartTypes): # [4.0] ~configs if self.askOpen(appname, 'Open part "%s"?' % basename): try: if sys.platform.startswith('win'): # Windows: via assoc, nonblocking # [4.0] nested spaces etc. okay here os.startfile(fullfilename) elif sys.platform.startswith('darwin'): # Mac: == click, nonblocking # [4.0] allow nested spaces, etc. from PP4E.launchmodes import quoteCmdlineItem fullfilename = quoteCmdlineItem(fullfilename) newinstances = [] if extension not in newinstances: os.system('open %s' % fullfilename) else: os.system('open -n %s' % fullfilename) # tbd (see above) elif sys.platform.startswith('linux'): # Linux (some): == click, nonblocking (normally?) # [4.0] allow nested spaces, etc. from PP4E.launchmodes import quoteCmdlineItem fullfilename = quoteCmdlineItem(fullfilename) os.system('xdg-open %s' % fullfilename) # Linux: tbd except: showerror(appname, 'Error opening document') printStack(sys.exc_info()) # 1.5 # punt (based on MIME type, even if file extension seems okay) else: # can still open in TempParts, or Split to and open in any folder wheresaved = os.path.dirname(fullfilename) msg = 'Cannot auto-open part:\n%s.' msg += '\n\nOpen it manually in:\n%s.' if wheresaved.endswith('TempParts'): # [4.0] expanded # but not if it failed during a Split! msg += '\n\nOr use Split to save to another folder.' msg = msg % (basename, wheresaved) showinfo(appname, fixTkBMP(msg)) self.refocusText() # [4.0] else Mac needs click # [4.0] open save folder in nonblocking file gui for convenience if sys.platform.startswith('darwin'): os.system('open ' + wheresaved) # Mac Finder elif sys.platform.startswith('win'): os.startfile(wheresaved) # Windows Explorer elif sys.platform.startswith('linux'): os.system('xdg-open ' + wheresaved) # Linux (some: tbd) ############################################################################### # message edit windows - write, reply, forward ############################################################################### if mailconfig.smtpuser: # user set in mailconfig? MailSenderClass = mailtools.MailSenderAuth # login/password required else: MailSenderClass = mailtools.MailSender # no login/password steps class WriteWindow(ViewWindow, MailSenderClass): """ customize view display for composing new mail inherits sendMessage from mailtools.MailSender """ modelabel = 'Write' # Dec2015: don't ask for smtp pswd again after the first successful send; # a class attribute: shared by N windows (ilke pswd, but used here only); sentOnce = False def __init__(self, headermap, starttext, referid=None): ViewWindow.__init__(self, headermap, starttext) MailSenderClass.__init__(self) self.attaches = [] # each win has own open dialog self.openDialog = None # dialog remembers last dir self.referid = referid # [4.1] for Reply/Fwd only 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 # [4.0] double space: parts are full paths here, unlike View if not self.attaches: showinfo(appname, 'Nothing attached') else: msg = '\n\n'.join(['Already attached:'] + self.attaches) showinfo(appname, fixTkBMP(msg)) self.refocusText() # [4.0] else Mac needs click 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; [4.0]: TBD - use parent=self for Mac slide-down sheet? [4.0]: pass initialfile/dir to avoid dialog failures if prior call picked file or path with non-BMP Unicode - a general tkinter bug (and is not fixed elsewere: see Split and Open), but seems most likely to occur here; """ if not self.openDialog: title = appname + ': Select Attachment File' if sys.platform.startswith('darwin'): dlgkargs = dict(message=title) # [4.0] Mac: title ignored else: dlgkargs = dict(title=title) # Windows+Linux: title works self.openDialog = Open(**dlgkargs) # [4.0] check saved prior file and path name choices for emojis priorfile = self.openDialog.options.get('initialfile', '') priorpath = self.openDialog.options.get('initialdir', '') if isNonBMP(priorpath): fixBMP = dict(initialdir=None, initialfile=None) # forget path+file elif isNonBMP(priorfile): fixBMP = dict(initialfile=None) # forget file only else: fixBMP = dict() # use both in this call filename = self.openDialog.show(**fixBMP) # remembers prior dir (+file) if filename: self.attaches.append(filename) # to be opened in send method self.refocusText() # [4.0] else Mac needs click 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; Dec2015, caveat: since Python 3.1, sending a large email (e.g., 30M) will cause all other threads, including the GUI, to bog down radically (on Windows, at least). There is no known solution to this; changing thread switch intervals seems useless, and adding time.sleep(0) calls to force thread switches (perhaps) is a non-starter, because send threads run standard library code during the send. Probably, the real fix is to recode the system to use processes instead of threads to transfer mail, but this is too radical a change to incorporate here: all shared memory would need to become IPC devices, and this is problematic because callbacks can't be pickled (see PP4E's multiprocessing coverage). For more on Python thread starvation in general, try a web search and/or: http://www.gossamer-threads.com/lists/python/python/983731. """ # 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)) #------------------------------------------------------------------------------ # [4.1] some email servers are starting to bounce emails that have no # Message-ID header (e.g., Gmail, sometimes), despite the fact that it's # optional per standard; this id should be added by the first MSA/MTA/MDA # server that receives the mail from the MUA client, but not all do (e.g., # GoDaddy SMTP servers don't, though some MDAs do as a last resort); add one # here, even though the GUI client's host may not have a usable domain name; # # this addition caused no bounces or spam listings on all systems tested; # do this in PyMailGUI instead of lower in mailtools - it's still a bit gray; # forge local domain name: make_msgid's socket.getfqdn runs 10sec on macos 10.15! # example: "Message-Id: <165594913533.36432.7066568876475893182@PyMailGUI.local>" # # related: Reply/Fwd also now sends In-Reply-To and References headers with # the Message-ID of the original message, if one is present, though all MUAs # tested seem to handle threading fine without these; for formatting details, # see http://www.faqs.org/rfcs/rfc2822.html; In-Reply-To is just Message-ID # if any, but References is complex and not implemented in full here (it's # just Message-ID because it doesn't seem to matter in practice); # # caveats: replies should also use Reply-To if present instead of From, but # this hasn't been in issue in over two decades of usage; ultimately, this # client's audience is also limited today by its lack of support for modern # paradigms and tools like HTML composition and IMAP; fix me if you care; #------------------------------------------------------------------------------ # [4.1] assume msg-ids not i18n encoded (though it's moot) if self.referid: extraHdrs.append( ('In-Reply-To', self.referid) ) # for reply and fwd extraHdrs.append( ('References', self.referid) ) # neither for write # [4.1] new id for all write/reply/fwd from email.utils import make_msgid extraHdrs.append( ('Message-Id', make_msgid(domain='PyMailGUI.local')) ) # withdraw to disallow send during send (else thread overlap impacts?) # caveat: might not be foolproof - user may deiconify if icon visible # Dec2015: use setSmtpPassword instead of getPassword for thread safety if not WriteWindow.sentOnce: # Dec2015: not after sent ok once self.setSmtpPassword() # if needed; don't do GUI in thread! # [4.0] pass self for geometry - open on parent initially on Linux # only; Windows and Mac pick rasonable spots, but Linux is miles away; poston = self if sys.platform.startswith('linux') else None popup = popuputil.BusyBoxNowait(appname, 'Sending message', poston) # [4.0] withdraw _after_ password input - need parent to transiently # attach to on Linux and Macs; a non-issue if Windows or passsword file; # also delay till busy box posted at window's prior spot on Linux; self.withdraw() 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): """ success: erase wait window and 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() if MailSenderClass.smtpPassword != None: # unless bad in other send WriteWindow.sentOnce = True # Dec2015: don't ask again # Dec2015, caveat: Windows explorer may shorten path names to 8.3 form # with "~" (e.g., after a right-click Winzip) in the folder of a script # about to be run, making pathname matches fail; Py's abspath() doesn't # fix this; workaround: start with a fresh file explorer after a Winzip. # poss \ when opened, / in mailconfig sentname = os.path.abspath(mailconfig.sentmailfile) # also expands '.' print('Saved in:', sentname) # None to disable #print(list(openSaveFiles.keys())) if sentname in openSaveFiles.keys(): # sent file open? window = openSaveFiles[sentname] # update list,raise window.loadMailFileThread() def onSendFail(self, exc_info, popup): """ failure: pop-up msg, keep msg window to save or retry, reshow actions; Dec2015, subtlety: it's not impossible that two initial sends may be run in parallel, and either may fail. If one succeeds first, the other should not be able to invalidate the password here; this might happen if the failure and exit actions of the two actually overlapped (and may require thread locks), but that's impossible given that exit actions are not threads, but are processed serially from the thread queue. If one send fails first, however, the exit action here may erase the password which would be validated by the success action of the other. Hence the check for a None password in the success exit action above! """ popup.quit() self.deiconify() self.lift() showerror(appname, fixTkBMP('Send failed: \n%s\n%s' % exc_info[:2])) self.refocusText() # [4.0] else Mac needs click printStack(exc_info) if not WriteWindow.sentOnce: # Dec2015: not after 1st send MailSenderClass.smtpPassword = None # try again; 3.0/4E: not self sendingBusy.decr() """ Dec2015: replaced the following with thread-safe scheme from POP side 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 passwd 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 """ def setSmtpPassword(self): # appname in sharedNames here """ get password if needed from GUI here, in main thread; forceably called from GUI to avoid popups in threads; Dec2015: extend to try local password file first in GUI thread (if set and exists), before asking user in popup; this effectively disables mailsender class's getPassword(), which really wasn't designed ideally for use in a GUI context; Dec2015, subtlety: though unlikely and dependent on Python's Tk implementation, it's not impossible that a send thread could check the password after Send sets it, and just after a prior send clears it due to a queued send-failure action (multiple Sends can always run in parallel with the main GUI thread, and send threads run fully independent of the GUI's actions and queue). To avoid hangs, must guarantee that the send thread never pops up password input dialog. [4.0] self to popup for transient+geometry on Linux and Mac OSX; Windows picks a reasonable spot and keeps modals on top of parent. """ if MailSenderClass != mailtools.MailSenderAuth: return # we're circumventing mailsender class logic here if MailSenderClass.smtpPassword is None: # Dec2015: allow '' pswds try: self.getPasswordFromFile() except: self.trace('%s %s' % (sys.exc_info()[0], sys.exc_info()[1])) prompt = 'Password for %s on %s?' % (self.smtpUser, self.smtpServerName) password = popuputil.askPasswordWindow(appname, prompt, self) MailSenderClass.smtpPassword = password # on class, not self def askSmtpPassword(self): """ but don't use GUI pop up here: I am run in a thread! when tried pop up in thread, caused GUI to hang; may still be called by MailSender superclass, but only if passwd is still empty string due to dialog close; user may have to Send again to input new password; """ return MailSenderClass.smtpPassword # or via self, but make explicit 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'