File: 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.
TBD: should Open dialog in write windows be program-wide? (per-window now).
###############################################################################
"""

from SharedNames import *     # program-wide global objects


###############################################################################
# message view window - also a superclass of write, reply, forward
###############################################################################


class ViewWindow(windows.PopupWindow, mailtools.MailParser):
    """
    a Toplevel, with extra protocol and embedded TextEditor;
    inherits saveParts,partsList from mailtools.MailParser;
    mixes in custom subclass logic by direct inheritance here;
    """
    # class attributes
    modelabel       = 'View'                   # used in window titles
    from mailconfig import okayToOpenParts     # open any attachments at all?
    from mailconfig import verifyPartOpens     # ask before open each part?
    from mailconfig import maxPartButtons      # show up to this many + '...'
    from mailconfig import skipTextOnHtmlPart  # 3.0: just browser, not PyEdit?
    tempPartDir     = 'TempParts'              # where 1 selected part saved

    # all view windows use same dialog: remembers last dir
    partsDialog = Directory(title=appname + ': Select parts save directory')

    def __init__(self, headermap, showtext, origmessage=None):
        """
        header map is origmessage, or custom hdr dict for writing;
        showtext is main text part of the message: parsed or custom;
        origmessage is parsed email.message.Message for view mail windows
        """
        windows.PopupWindow.__init__(self, appname, self.modelabel)
        self.origMessage = origmessage         
        self.makeWidgets(headermap, showtext)

    def makeWidgets(self, headermap, showtext):
        """
        add headers, actions, attachments, text editor
        3.0: showtext is assumed to be decoded Unicode str here;
        it will be encoded on sends and saves as directed/needed;
        """
        actionsframe = self.makeHeaders(headermap)
        if self.origMessage and self.okayToOpenParts:
            self.makePartButtons()
        self.editor  = textEditor.TextEditorComponentMinimal(self)
        myactions    = self.actionButtons()
        for (label, callback) in myactions:
            b = Button(actionsframe, text=label, command=callback)
            b.config(bg='beige', relief=RIDGE, bd=2)
            b.pack(side=TOP, expand=YES, fill=BOTH)

        # body text, pack last=clip first
        self.editor.pack(side=BOTTOM)               # may be multiple editors
        self.update()                               # 3.0: else may be @ line2

        #------------------------------------------------------------------
        # Jan 2014, 1.5: setAllText failed once in 3.3 (after a decade of
        # daily use!), leaving thread-busy lock locked.  Looks like a Tk
        # limitation -- it couldn't handle a "speak-no-evil monkey" Unicode
        # character > 16 bits in the text part of a text+html alternative
        # message (the html part uses a link instead) -- but this shouldn't
        # leave the GUI crippled due to an uncaught exception and busy lock
        # (it could no longer Load, and had to be closed in TaskManager on
        # Windows).  The exception's traceback in the console window:
        #
        # File "..\ViewWindows.py", line 75, in makeWidgets
        #    self.editor.setAllText(showtext)            # each has own content
        # File "C:\PP4E\...\Gui\TextEditor\textEditor.py", line 873, in setAllText
        #    self.text.insert(END, text)               # or '1.0'; text=bytes or str
        # File "C:\Python33\lib\tkinter\__init__.py", line 3095, in insert
        #    self.tk.call((self._w, 'insert', index, chars) + args)
        # ValueError: character U+1f64a is above the range (U+0000-U+FFFF) allowed by Tcl
        #------------------------------------------------------------------
        #self.editor.setAllText(showtext)           # each has own content
        try:
            self.editor.setAllText(showtext)        # each has own content
        except:
            showerror(appname, 'Error setting text in view window')
            printStack(sys.exc_info())
        
        lines = len(showtext.splitlines())
        lines = min(lines + 3, mailconfig.viewheight or 20)
        self.editor.setHeight(lines)                # else height=24, width=80
        self.editor.setWidth(80)                    # or from PyEdit textConfig
        if mailconfig.viewbg:
            self.editor.setBg(mailconfig.viewbg)    # colors, font in mailconfig
        if mailconfig.viewfg:
            self.editor.setFg(mailconfig.viewfg)
        if mailconfig.viewfont:                     # also via editor Tools menu
            self.editor.setFont(mailconfig.viewfont)

    def makeHeaders(self, headermap):
        """
        add header entry fields, return action buttons frame;
        3.0: uses grid for platform-neutral layout of label/entry rows;
        packed row frames with fixed-width labels would work well too;

        3.0: decoding of i18n headers (and email names in address headers)
        is performed here if still required as they are added to the GUI;
        some may have been decoded already for reply/forward windows that 
        need to use decoded text, but the extra decode here is harmless for
        these, and is required for other headers and cases such as fetched 
        mail views;  always, headers are in decoded form when displayed in
        the GUI, and will be encoded within mailtools on Sends if they are 
        non-ASCII (see Write);  i18n header decoding also occurs in list 
        window mail indexes, and for headers added to quoted mail text;
        text payloads in the mail body are also decoded for display and 
        encoded for sends elsewhere in the system (list windows, Write);

        3.0: creators of edit windows prefill Bcc header with sender email
        address to be picked up here, as a convenience for common usages if
        this header is enabled in mailconfig;  Reply also now prefills the
        Cc header with all unique original recipients less From, if enabled;
        """
        top    = Frame(self); top.pack   (side=TOP,   fill=X)
        left   = Frame(top);  left.pack  (side=LEFT,  expand=NO,  fill=BOTH)
        middle = Frame(top);  middle.pack(side=LEFT,  expand=YES, fill=X)

        # headers set may be extended in mailconfig (Bcc, others?)
        self.userHdrs = ()
        showhdrs = ('From', 'To', 'Cc', 'Subject')
        if hasattr(mailconfig, 'viewheaders') and mailconfig.viewheaders:
            self.userHdrs = mailconfig.viewheaders
            showhdrs += self.userHdrs
        addrhdrs = ('From', 'To', 'Cc', 'Bcc')    # 3.0: decode i18n specially
            
        self.hdrFields = []
        for (i, header) in enumerate(showhdrs):
            lab = Label(middle, text=header+':', justify=LEFT)
            ent = Entry(middle)
            lab.grid(row=i, column=0, sticky=EW)
            ent.grid(row=i, column=1, sticky=EW)
            middle.rowconfigure(i, weight=1)
            hdrvalue = headermap.get(header, '?')    # might be empty
            # 3.0: if encoded, decode per email+mime+unicode
            if header not in addrhdrs:
                hdrvalue = self.decodeHeader(hdrvalue)
            else:
                hdrvalue = self.decodeAddrHeader(hdrvalue)
            ent.insert('0', hdrvalue)
            self.hdrFields.append(ent)               # order matters in onSend
        middle.columnconfigure(1, weight=1)
        return left

    def actionButtons(self):                         # must be method for self
        return [('Cancel', self.destroy),            # close view window silently
                ('Parts',  self.onParts),            # multiparts list or the body
                ('Split',  self.onSplit)]

    def makePartButtons(self):
        """
        add up to N buttons that open attachments/parts
        when clicked; alternative to Parts/Split (2.1);
        okay that temp dir is shared by all open messages:
        part file not saved till later selected and opened;
        partname=partname is required in lambda in Py2.4;
        caveat: we could try to skip the main text part;
        """
        def makeButton(parent, text, callback):
            link = Button(parent, text=text, command=callback, relief=SUNKEN)
            if mailconfig.partfg: link.config(fg=mailconfig.partfg)
            if mailconfig.partbg: link.config(bg=mailconfig.partbg)
            link.pack(side=LEFT, fill=X, expand=YES)

        parts = Frame(self)
        parts.pack(side=TOP, expand=NO, fill=X)
        for (count, partname) in enumerate(self.partsList(self.origMessage)):
            if count == self.maxPartButtons:
                makeButton(parts, '...', self.onSplit)
                break
            openpart = (lambda partname=partname: self.onOnePart(partname))
            makeButton(parts, partname, openpart)

    def onOnePart(self, partname):
        """
        locate selected part for button and save and open;
        okay if multiple mails open: resaves each time selected;
        we could probably just use web browser directly here;
        caveat: tempPartDir is relative to cwd - poss anywhere;
        caveat: tempPartDir is never cleaned up: might be large,
        could use tempfile module (just like the HTML main text 
        part display code in onView of the list window class);
        """
        try:
            savedir  = self.tempPartDir
            message  = self.origMessage
            (contype, savepath) = self.saveOnePart(savedir, partname, message)
        except:
            showerror(appname, 'Error while writing part file')
            printStack(sys.exc_info())
        else:
            self.openParts([(contype, os.path.abspath(savepath))])   # reuse

    def onParts(self):
        """
        show message part/attachments in pop-up window;
        uses same file naming scheme as save on Split;
        if non-multipart, single part = full body text
        """
        partnames = self.partsList(self.origMessage)
        msg = '\n'.join(['Message parts:\n'] + partnames)
        showinfo(appname, msg)

    def onSplit(self):
        """
        pop up save dir dialog and save all parts/attachments there;
        if desired, pop up HTML and multimedia parts in web browser,
        text in TextEditor, and well-known doc types on windows;
        could show parts in View windows where embedded text editor
        would provide a save button, but most are not readable text;
        """
        savedir = self.partsDialog.show()          # class attr: at prior dir
        if savedir:                                # tk dir chooser, not file
            try:
                partfiles = self.saveParts(savedir, self.origMessage)
            except:
                showerror(appname, 'Error while writing part files')
                printStack(sys.exc_info())
            else:
                if self.okayToOpenParts: self.openParts(partfiles)

    def askOpen(self, appname, prompt):
        if not self.verifyPartOpens:
            return True
        else:
            return askyesno(appname, prompt)   # pop-up dialog

    def openParts(self, partfiles):
        """
        auto-open well known and safe file types, but only if verified 
        by the user in a pop up; other types must be opened manually 
        from save dir;  at this point, the named parts have been already
        MIME-decoded and saved as raw bytes in binary-mode files, but text 
        parts may be in any Unicode encoding;  PyEdit needs to know the
        encoding to decode, webbrowsers may have to guess or be told;

        caveat: punts for type application/octet-stream even if it has 
        safe filename extension such as .html; caveat: image/audio/video
        could be opened with the book's playfile.py; could also do that 
        if text viewer fails: would start notepad on Windows via startfile;
        webbrowser may handle most cases here too, but specific is better;
        """

        def textPartEncoding(fullfilename):
            """
            3.0: map a text part filename back to charset param in content-type 
            header of part's Message, so we can pass this on to the PyEdit 
            constructor for proper text display;  we could return the charset
            along with content-type from mailtools for text parts, but fewer
            changes are needed if this is handled as a special case here;

            part content is saved in binary mode files by mailtools to avoid 
            encoding issues, but here the original part Message is not directly 
            available; we need this mapping step to extract a Unicode encoding 
            name if present; 4E's PyEdit now allows an explicit encoding name for 
            file opens, and resolves encoding on saves; see Chapter 11 for PyEdit
            policies: it may ask user for an encoding if charset absent or fails;
            caveat: move to mailtools.mailParser to reuse for <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
                        encoding = textPartEncoding(fullfilename)
                        textEditor.TextEditorMainPopup(parent=self,
                                   winTitle=' - %s email part' % (encoding or '?'),
                                   loadFirst=fullfilename, loadEncode=encoding)
                    except:
                        showerror(appname, 'Error opening text viewer')
                        printStack(sys.exc_info())  # 1.5

            # text/plain, text/x-python, etc.; 4E: encoding, may fail
            elif maintype == 'text':
                if self.askOpen(appname, 'Open text part "%s"?' % basename):
                    try:
                        encoding = textPartEncoding(fullfilename)
                        textEditor.TextEditorMainPopup(parent=self,
                                   winTitle=' - %s email part' % (encoding or '?'),
                                   loadFirst=fullfilename, loadEncode=encoding)
                    except:
                        # Jan 1014, 1.5: may also fail, but doesn't keep busy lock
                        showerror(appname, 'Error opening text viewer')
                        printStack(sys.exc_info())  # 1.5

            # multimedia types: Windows opens mediaplayer, imageviewer, etc.
            elif maintype in ['image', 'audio', 'video']:
                if self.askOpen(appname, 'Open media part "%s"?' % basename):
                    try:
                        webbrowser.open_new('file://' + fullfilename)
                    except:
                        showerror(appname, 'Error opening browser')
                        printStack(sys.exc_info())  # 1.5

            # common Windows documents: Word, Excel, Adobe, archives, etc.
            elif (sys.platform[:3] == 'win' and
                  maintype == 'application' and                      # 3.0: +x types
                  extension in ['.doc', '.docx', '.xls', '.xlsx',    # generalize me
                                '.pdf', '.zip',  '.tar', '.wmv']):
                    if self.askOpen(appname, 'Open part "%s"?' % basename):
                        os.startfile(fullfilename)

            else:  # punt!
                msg = 'Cannot open part: "%s"\nOpen manually in: "%s"'
                msg = msg % (basename, os.path.dirname(fullfilename))
                showinfo(appname, msg)


###############################################################################
# message edit windows - write, reply, forward
###############################################################################


if mailconfig.smtpuser:                              # user set in mailconfig?
    MailSenderClass = mailtools.MailSenderAuth       # login/password required
else:
    MailSenderClass = mailtools.MailSender


class WriteWindow(ViewWindow, MailSenderClass):
    """
    customize view display for composing new mail
    inherits sendMessage from mailtools.MailSender
    """
    modelabel = 'Write'

    def __init__(self, headermap, starttext):
        ViewWindow.__init__(self, headermap, starttext)
        MailSenderClass.__init__(self)
        self.attaches   = []                     # each win has own open dialog
        self.openDialog = None                   # dialog remembers last dir

    def actionButtons(self):
        return [('Cancel', self.quit),           # need method to use self
                ('Parts',  self.onParts),        # PopupWindow verifies cancel
                ('Attach', self.onAttach),
                ('Send',   self.onSend)]         # 4E: don't pad: centered

    def onParts(self):
        # caveat: deletes not currently supported
        if not self.attaches:
            showinfo(appname, 'Nothing attached')
        else:
            msg = '\n'.join(['Already attached:\n'] + self.attaches)
            showinfo(appname, msg)

    def onAttach(self):
        """
        attach a file to the mail: name added here will be
        added as a part on Send, inside the mailtools pkg;
        4E: could ask Unicode type here instead of on send
        """
        if not self.openDialog:
            self.openDialog = Open(title=appname + ': Select Attachment File')
        filename = self.openDialog.show()        # remember prior dir
        if filename:
            self.attaches.append(filename)       # to be opened in send method

    def resolveUnicodeEncodings(self):
        """
        3.0/4E: to prepare for send, resolve Unicode encoding for text parts:
        both main text part, and any text part attachments;  the main text part
        may have had a known encoding if this is a reply or forward, but not for
        a write, and it may require a different encoding after editing anyhow;
        smtplib in 3.1 requires that full message text be encodable per ASCII
        when sent (if it's a str), so it's crucial to get this right here; else
        fails if reply/fwd to UTF8 text when config=ascii if any non-ascii chars;
        try user setting and reply but fall back on general UTF8 as a last resort;
        """

        def isTextKind(filename):
            contype, encoding = mimetypes.guess_type(filename)
            if contype is None or encoding is not None:    # 4E utility
                return False                               # no guess, compressed?
            maintype, subtype = contype.split('/', 1)      # check for text/?
            return maintype == 'text'                   

        # resolve many body text encoding
        bodytextEncoding = mailconfig.mainTextEncoding
        if bodytextEncoding == None:
            asknow = askstring('PyMailGUI', 'Enter main text Unicode encoding name')
            bodytextEncoding = asknow or 'latin-1'    # or sys.getdefaultencoding()?

        # last chance: use utf-8 if can't encode per prior selections
        if bodytextEncoding != 'utf-8':
            try:
                bodytext = self.editor.getAllText()
                bodytext.encode(bodytextEncoding)
            except (UnicodeError, LookupError):       # lookup: bad encoding name
                bodytextEncoding = 'utf-8'            # general code point scheme

        # resolve any text part attachment encodings
        attachesEncodings = []
        config = mailconfig.attachmentTextEncoding
        for filename in self.attaches:
            if not isTextKind(filename):
                attachesEncodings.append(None)        # skip non-text: don't ask
            elif config != None:
                attachesEncodings.append(config)      # for all text parts if set
            else:
                prompt = 'Enter Unicode encoding name for %' % filename
                asknow = askstring('PyMailGUI', prompt)
                attachesEncodings.append(asknow or 'latin-1')

            # last chance: use utf-8 if can't decode per prior selections
            choice = attachesEncodings[-1]
            if choice != None and choice != 'utf-8':
                try:
                    attachbytes = open(filename, 'rb').read()
                    attachbytes.decode(choice)
                except (UnicodeError, LookupError, IOError):
                    attachesEncodings[-1] = 'utf-8'
        return bodytextEncoding, attachesEncodings

    def onSend(self):
        """
        threaded: mail edit window Send button press;
        may overlap with any other thread, disables none but quit;
        Exit,Fail run by threadChecker via queue in after callback;
        caveat: no progress here, because send mail call is atomic;
        assumes multiple recipient addrs are separated with ',';
        mailtools module handles encodings, attachments, Date, etc; 
        mailtools module also saves sent message text in a local file

        3.0: now fully parses To,Cc,Bcc (in mailtools) instead of 
        splitting on the separator naively;  could also use multiline
        input widgets instead of simple entry;  Bcc added to envelope,
        not headers;

        3.0: Unicode encodings of text parts is resolved here, because
        it may require GUI prompts;  mailtools performs the actual 
        encoding for parts as needed and requested;

        3.0: i18n headers are already decoded in the GUI fields here; 
        encoding of any non-ASCII i18n headers is performed in mailtools,
        not here, because no GUI interaction is required;
        """
       
        # resolve Unicode encoding for text parts;
        bodytextEncoding, attachesEncodings = self.resolveUnicodeEncodings()

        # get components from GUI; 3.0: i18n headers are decoded
        fieldvalues = [entry.get() for entry in self.hdrFields]
        From, To, Cc, Subj = fieldvalues[:4]
        extraHdrs = [('Cc', Cc), ('X-Mailer', appname + ' (Python)')]
        extraHdrs += list(zip(self.userHdrs, fieldvalues[4:]))
        bodytext = self.editor.getAllText()

        # split multiple recipient lists on ',', fix empty fields
        Tos = self.splitAddresses(To)
        for (ix, (name, value)) in enumerate(extraHdrs):
            if value:                                           # ignored if ''
                if value == '?':                                # ? not replaced
                    extraHdrs[ix] = (name, '')
                elif name.lower() in ['cc', 'bcc']:             # split on ','
                    extraHdrs[ix] = (name, self.splitAddresses(value))

        # withdraw to disallow send during send
        # caveat: might not be foolproof - user may deiconify if icon visible 
        self.withdraw()
        self.getPassword()      # if needed; don't run pop up in send thread!
        popup = popuputil.BusyBoxNowait(appname, 'Sending message')
        sendingBusy.incr()
        threadtools.startThread(
            action  = self.sendMessage,
            args    = (From, Tos, Subj, extraHdrs, bodytext, self.attaches,
                                         saveMailSeparator,
                                         bodytextEncoding,
                                         attachesEncodings),
            context = (popup,),
            onExit  = self.onSendExit,
            onFail  = self.onSendFail)

    def onSendExit(self, popup):
        """
        erase wait window, erase view window, decr send count;
        sendMessage call auto saves sent message in local file;
        can't use window.addSavedMails: mail text unavailable;
        """
        popup.quit()
        self.destroy()
        sendingBusy.decr()

        # poss \ when opened, / in mailconfig
        sentname = os.path.abspath(mailconfig.sentmailfile)  # also expands '.'
        if sentname in openSaveFiles.keys():                 # sent file open?
            window = openSaveFiles[sentname]                 # update list,raise
            window.loadMailFileThread()

    def onSendFail(self, exc_info, popup):
        # pop-up error, keep msg window to save or retry, redraw actions frame
        popup.quit()
        self.deiconify()
        self.lift()
        showerror(appname, 'Send failed: \n%s\n%s' % exc_info[:2])
        printStack(exc_info)
        MailSenderClass.smtpPassword = None        # try again; 3.0/4E: not self
        sendingBusy.decr()

    def askSmtpPassword(self):
        """
        get password if needed from GUI here, in main thread;
        caveat: may try this again in thread if no input first
        time, so goes into a loop until input is provided; see
        pop paswd input logic for a nonlooping alternative
        """
        password = ''
        while not password:
            prompt = ('Password for %s on %s?' %
                     (self.smtpUser, self.smtpServerName))
            password = popuputil.askPasswordWindow(appname, prompt)
        return password


class ReplyWindow(WriteWindow):
    """
    customize write display for replying
    text and headers set up by list window
    """
    modelabel = 'Reply'


class ForwardWindow(WriteWindow):
    """
    customize reply display for forwarding
    text and headers set up by list window
    """
    modelabel = 'Forward'



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