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'



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