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'