Python curses mail client

Kragen Javier Sitaker kragen at pobox.com
Sat Oct 7 03:37:01 EDT 2006


I finally have a mail client that is, in many ways, better than
less(1) for reading email.  It has tagging, searching, index display,
reasonable message display, and it runs reasonably fast (more than an
order of magnitude faster than Pine or mutt) but no replying or
email-writing functionality yet.  Still, it's made a bunch of my past
email noticeably more accessible.

Here's the history:

Well, back in 1999, I started writing on kragen-tol about how I wanted
a better mail client, which I called "ecommit", which (due to current
circumstances) expands to "electronic communication for the
information technologist":
    <19990517020450.5630.qmail at kragen.dnaco.net>
  "vaporware ecommit"
    <Pine.GSO.4.10.9907161002350.23163-100000 at kirk.dnaco.net>

(Sorry to not include URLs for all these old mailing list posts; I'm
off the 'net at the moment and don't have the URL structure of my mail
archives written down.)

I've spent some time writing programs that help me read my email:
  Grovelmail, in 2000, forwarded selected messages to my pager:
    "grovelmail"
      <Pine.GSO.4.21.0008270839390.23364-100000 at kirk.dnaco.net>
    "grovelmail CORRECTION"
      <Pine.GSO.4.21.0008270855430.23751-100000 at kirk.dnaco.net>
  mailmsg.py, in 2001, was a sort of MUA that used Emacs's
  outline-mode for its UI.  (Dave Winer was very unimpressed when he
  saw it.)
    "a novel toy MUA"
      <Pine.GSO.4.21.0101160551270.23832-100000 at kirk.dnaco.net>
    "outline-based mailreader is usable"
      <Pine.GSO.4.21.0101230341180.10819-100000 at kirk.dnaco.net>
    "newer mailmsg.py"
      <200101251821.NAA20772 at kirk.dnaco.net>
    "the current iteration of my MUA"
      <200103020722.CAA15320 at kirk.dnaco.net>
  In 2002, I wrote some elisp macros that interface with mailmsg.py
  (because I'd given up using it for reading email and was just using
  it for sending):
    "my email composition system"
      <20020215034712.8FEEABDF3 at panacea.canonical.org>
  Four months later, in 2002, I wrote a program to burn a CD of
  synthesized speech from a mailbox file, so that I could listen to
  the CD in transit:
    "reading email with a CD player"
      <20020503204336.C0E06BDC4 at panacea.canonical.org>
  The same month, I wrote 'syncmaildir', a small program to
  synchronize maildirs with rsync, so that I could read mail offline
  with mutt:
    "synchronizing maildirs with rsync"
      <20020526075950.1D645BDC4 at panacea.canonical.org>
    "updated syncmaildir"
      <20020613015823.496B0BDC4 at panacea.canonical.org>
  In 2003, I wrote a prototype with ZODB and PyGTK that displayed a
  message index:
    "beginnings of a mailreader"
      <20030221065528.C55D53F570 at panacea.canonical.org>
  In 2004, I wrote a "quick-and-dirty prototype of query-based
  mailreading" which I called "mailquery" --- it's somewhat like
  mboxgrep with fielded search:
    "brute-force querying mbox files"
      <20040125231557.3168A3F61E at panacea.canonical.org>
  In 2004, I also started writing a full-text indexer for my email,
  called "maildex.py" and then "merge.py", which had the unusual
  feature that the index files were plain ASCII text:
    "full-text search of email"
      <20040229095212.5B0AD3F458 at panacea.canonical.org>
    "full-text indexing of arbitrarily large corpuses in mbox format"
      <20040307011129.903593F46D at panacea.canonical.org>
  And then I rewrote the indexing part in C:
    "faster full-text mbox indexing"
      <20040307011129.A0F9C3F46A at panacea.canonical.org>
    "fast mbox inverted indexing in C"
      <20040413152632.793823F48C at panacea.canonical.org>
  And then I rewrote the merging part in C:
    "merging mailbox indices faster (in C)"
      <20041010004907.2DE6D3F4BE at panacea.canonical.org>
  And then I optimized it some more:
    "faster mail indexing in C"
      <20041014032745.D69353F4B2 at panacea.canonical.org>
  In 2005, I started working on the problem of indexing corpuses
  larger than virtual memory (mmap simplified the code but imposed
  this limitation) but I don't think I ever hooked the indexing code 
  up to it:
    "sliding file windows over an mbox (for full-text indexing)"
      <20050419081519.C3A603F526 at panacea.canonical.org>
  In 2006, because I didn't have my own laptop and Bea's didn't have a
  C compiler, I started working on a new text indexer that supported
  mailbox files and used a novel underlying data structure:
    "full-text indexing with hash hints (Bloom-filter-like) (implementation)"
      <20060802164628.C1DAAE34101 at panacea.canonical.org>
    "my new full-text indexer"
      <20060818024519.C772FE3410F at panacea.canonical.org>
  I also created a CGI front-end to mailmsg.py so I could send email
  from "cibercafe" machines without giving them my password:
    "uploading mail batches"
      <20060905191537.18B5FE34161 at panacea.canonical.org>
  
In March of this year, I started writing yet another MUA, but it still
hasn't gotten good enough to post to kragen-hacks.  I've continued
working on it on and off: according to darcs, I've committed 79
patches to it, in March, April, June, September, and October.
However, it *is* good enough that I have been using it a lot, so I'm
posting it anyway.

There are dozens of things wrong with it; here are a few of the most
egregious:

- it uses curses.  Isn't that bad enough by itself?
- at some times, hitting ^C aborts the current time-consuming operation;
  at other times, it dumps you out of the program.  This is a bummer if 
  that operation just finished.
- it completely fails to handle screen resizes, sometimes crashing
- the editing mode (for tags and searches) is a mode
- searching (with '/') is completely undocumented, but searching for
  foo bar finds messages containing both 'foo' and 'bar', while
  searching for foo @bar @baz finds messages containing 'foo' and
  tagged with both 'bar' and 'baz'.  - at spam is a useful search.
- many of the other commands are undocumented too (like left-arrow to see the
  message index, 'f' to save the current message, '\' to toggle source view,
  's' to toggle the tag 'spam')
- there's no way to scroll up.  Can you believe I've been working on
  it (and using it) for months without adding a scroll-up command?
- the editing mode (for tags and searches) has a terrible UI (inherited
  from the curses.textpad module, so at least I didn't have to write it)
- it's slower than less(1) at going to the end of a mailbox file (5
  minutes for a 1GB mailbox, in contrast to less's <200ms)
- it wastes screen real estate by displaying only one message
- because it uses curses, it pretty much sucks at charset handling.
- it uses blinking text.  Christ.  Blinking text.
- there are lots of internal problems too: lots of duplicated code,
  overlong routines, misnamed classes, use of undocumented interfaces,
  inconsistent naming, overcomplicated code, stamp coupling, total
  failure to comment code, functions that ought to be methods,
  surprising return values, magic numbers, inefficient algorithms, and
  so on.

So you can see why I've put off posting it to kragen-hacks for more
than six months, and am prepending two pages of apologies to it.

#!/usr/bin/python
import curses, time, cgitb, sys, email, mailbox, re, os, curses.textpad, cPickle

# Embarrassingly ugly and fairly minimal mail reader.
# TODO:
# - CLEAN UP MESSY AND DUPLICATED CODE
# - parse search terms only once, into search term objects.
# D go to previous message
# - display headers (well, minimally happening now)
#   D in default display, display only person's name, not email address (or
#     vice versa)
#   D make subject not wrap onto subsequent lines
#   D provide a command for full message headers display
# D speed up index display!
# D scroll around message
# - take other actions such as bounce, approve, reply, or tag
#   D got a primitive 'tag' function
#   D add some amount of filtering
# - refactor:
#   - don't use email.Message for parsing?
#   - remove duplication among many redraw() calls
#     - now it's down to two
# - make it possible to display multiple messages
# D handle IndexError without crashing
# - handle other exceptions without crashing
# - cache message summary data on disk?
# - adjust to screen resizing
#   - probably not possible without modifying Python curses binding to
#     support resizeterm(3NCURSES).  Maybe use ctypes?
#     - how does Urwid do it?
# - display multiple messages at once
# - interface with mailman code
# - search
#   - ok, faster search, using an index!
# D make it lazier about loading messages
# D support fielded searches (to:kragen s:[silk]) for better speed and accuracy
#   X done in a really crappy way
# D just use file.read() for .as_string()
# - implement command-line history for searches
# - how about page-up and page-down (KEY_NPAGE, KEY_PPAGE) to display next
#   or previous summary page?  Searches are now fast enough that's worthwhile...
# - how about message history? ('go to last-seen message')
# - make stuff more concurrent so as to prefetch search results and message
#   metadata
# - handle ^C while waiting for keystroke sanely?  May require
#   becoming more event-driven, since it's possible to ^C the "while
#   1:" and things like that.
# - display menu letters in different color rather than in []
# - support initial backward searches (?@untagged RET)
# - maybe transcode for display charsets like ISO-8859-1?
#   - both in contents and in =?ISO-8859-1?Q?=BFno=3F?=
# - come up with a way to go to the end of the mailbox quickly!

# Mail parsing performance on my 1.1GHz laptop is on the order of 2
# megabytes and 200 messages per second.  For my current email, it
# uses 462 bytes of virtual memory per message. It used to use only 19
# bytes per message, but I wanted to be able to do some kinds of
# searches quickly, without reading and reparsing the entire mailbox
# again.

# So, once it's fully parsed my nearly-1-gig mailbox, it needs less
# than 50MB of virtual memory, which is good.  However, it needed
# something like 7 minutes of CPU time to do that, which is still too
# slow --- if it started out displaying the last message instead of
# the first, I'd be happy.

# OK, now I pickle the current state when the user hits '>' (pickling
# takes 4-5 seconds) and unpickle it at startup (another maybe 5
# seconds).  The pickled file is roughly half the size of the virtual
# memory footprint (18 MB in my case), so it's not a big deal.  The
# 5-second startup is still a big deal (to me), as is the potential
# for fragility.

def cargo_cult_routine(win):
    win.clear()
    win.refresh()
    curses.nl()
    curses.noecho()

class msglines:
    def __init__(self, body): self.lines = body.split('\n')
    def __getitem__(self, ii):
        if ii < len(self.lines): return self.lines[ii]
        else: return ''

# I was using UnixMailbox, but it broke on squeak-dev archives, which look
# like this:
# From johnmci at smalltalkconsulting.com  Sat May  1 00:52:54 2004
# so now I use PortableUnixMailbox instead.
class SeekableUnixMailbox(mailbox.PortableUnixMailbox):
    def tell(self): return self.seekp
    def seek(self, pointer): self.seekp = pointer

def fastparse(fp):
    wsp = re.compile(r'\s+')
    hdr = re.compile(r'([^\s:]+):\s+(.*)')
    curhdr = None
    rv = {}
    while 1:
        line = fp.readline()
        while line.endswith('\n') or line.endswith('\r'): line = line[:-1]
        h = hdr.match(line)
        if h:
            curhdr = h.group(1).lower()
            rv[curhdr] = h.group(2)
        elif wsp.match(line): rv[curhdr] += '\n' + line
        elif not line:
            return rv
        else:
            pass # probably the From line

# Identifying headers produced by various mailing list managers:
#               Mailman  Listserv  ezmlm  Yahoo Groups  Google Groups  Majordomo
# Sender        X        X         -      X             X              X
# Mailing-List  -        -         X      X             X              -
# List-Id       X        -         -      X             X              -
# List-Post     X        -         X      -             X              -

# So if we had to pick just one header to make quickly available for
# mailing-list filtering, it would be Sender, because Listserv and
# Majordomo only support Sender.  But Sender doesn't support ezmlm,
# and usually doesn't contain the actual list name; some examples:
# Sender                                            List address                                 Software       
# listname+jdoe=example.com at lists.example.com       listname at lists.example.com                   Mailman        
# beowulf-bounces at beowulf.org                       beowulf at beowulf.org                          Mailman        
# owner-kabuki-west at postmodern.com                  kabuki-west at postmodern.com                   Majordomo      
# bytesforall_readers at yahoogroups.com               bytesforall_readers at yahoogroups.com          Yahoo Groups   
# Vanagon Mailing List <vanagon at gerry.vanagon.com>  vanagon at gerry.vanagon.com                    Listserv       
# Entropy-Gradient-Reversals at googlegroups.com       Entropy-Gradient-Reversals at googlegroups.com  Google Groups  

# Like Sender, Mailing-List usually doesn't contain the actual list
# address.  The others (List-Id and List-Post) usually do, so I'm
# going to use List-Post.

def mintern(obj):
    try: return intern(obj)
    except TypeError: return obj

class MessageProxy:
    # 41988K 6:10
    # keys = 'from subject message-id date'.split()

    # Sender and List-Post allow identification of most mailing lists.
    # 48784K 5:40
    # keys = 'from subject message-id date sender list-post'.split()
    # 56264K 6:32 without interning; 44116K 7:05 with interning
    keys = 'from subject message-id date sender list-post to cc'.split()
    def __init__(self, fileobj):
        self.fileobj = fileobj
        self._fastparse = None
        self._msg = None
        self.cached_metadata = {}
    def __repr__(self): return '<MessageProxy %r>' % (self.__dict__,)
    def msg(self):
        if self._msg is not None: return self._msg
        self.fileobj.seek(0)
        self._msg = email.message_from_file(self.fileobj)
        return self._msg
    def fastparse(self):
        # This speeds up e.g. searching for tags in a previously
        # unread part of the mailbox by about a factor of pi:
        if self._fastparse is None:
            self.fileobj.seek(0)
            self._fastparse = fastparse(self.fileobj)
        return self._fastparse
    def __getitem__(self, key):
        if key in self.cached_metadata: return self.cached_metadata[key]
        # For efficiency, crash the program and make the programmer
        # think about the time/space tradeoffs, and fix it, instead of
        # running slowly.
        elif key not in self.keys: raise KeyError, key
        return mintern(self.fastparse()[key])
    def get(self, key, default=None):
        if self.cached_metadata.get(key) is not None:
            return self.cached_metadata[key]
        elif key not in self.keys: raise KeyError, key
        return mintern(self.fastparse().get(key, default))
    def as_string(self):
        # This is at least 10x faster than self.msg().as_string():
        self.fileobj.seek(0)
        return self.fileobj.read()
    def get_payload(self):
        # This is the only operation that routinely still reads from
        # the file, and the only operation that uses the slow
        # email.Message parser instead of fastparse:
        return self.msg().get_payload()        
    # hmm, maybe this should be a different kind of object, one with
    # the cached metadata:
    def cached_metadata_is(self, values):
        for key, value in zip(self.keys, values):
            self.cached_metadata[key] = value

class MessageListFacade:
    def __init__(self, fp):
        self.fp = fp
        # We don't really care what kind of objects self.mbox.next()
        # returns, as long as they aren't None.
        self.mbox = SeekableUnixMailbox(fp, lambda subfile: subfile)
        self.msgs = [self.mbox.tell()]
        self.metadata = []
    def __getitem__(self, index):
        self.mbox.seek(self.msgs[-1])
        while index + 1 >= len(self.msgs):
            msg = self.mbox.next()
            if msg is None:
                # Someone was unclear on the iterator protocol
                # when they created the mailbox module; should
                # have used StopIteration!
                raise IndexError(index)
            # This puts the end of each message onto self.msgs
            self.msgs.append(self.mbox.tell())
        subfile = mailbox._Subfile(self.fp,
                                   self.msgs[index], self.msgs[index+1])
        rv = MessageProxy(subfile)
        while len(self.metadata) <= index: self.metadata.append(None)
        if not self.metadata[index]:
            self.metadata[index] = tuple(map(rv.get, rv.keys))
        rv.cached_metadata_is(self.metadata[index])
        return rv
    def write_cached_metadata_to(self, cachefilename):
        try:
            newfilename = cachefilename + '.new'
            outfile = file(newfilename, 'w')
            cPickle.dump(self.metadata, outfile, 2)
            cPickle.dump(self.msgs, outfile, 2)
            outfile.close()
            os.rename(newfilename, cachefilename)
        except KeyboardInterrupt: pass
    def read_cached_metadata(self, cachefilename):
        try: infile = file(cachefilename)
        except IOError: return
        self.metadata = cPickle.load(infile)
        self.msgs = cPickle.load(infile)
        # XXX need to validate this

class tagstore:
    def __init__(self, filename=None):
        if filename is None:
            filename = os.path.join(os.environ['HOME'], '.cursmailmsgtags')
        self.file = file(filename, 'a+')
        self.tags = {}
        for line in self.file:
            fields = line.split()
            msgid = fields[0]
            self.set_tags(msgid, fields[1:])
    def __getitem__(self, msgid):
        try: return self.tags[msgid]
        except KeyError: return ('untagged',)
    def has_key(self, msgid):
        return self.tags.has_key(msgid)
    def set_tags(self, msgid, tags):
        self.tags[msgid] = tuple(tags)
    def __setitem__(self, msgid, tags):
        if tags == (): tags = ('untagged',)
        self.set_tags(msgid, tags)
        self.file.write(' '.join([msgid] + list(tags)) + '\n')
        self.file.flush()

def body(msg):
    # Whoever was designing the email.Message API was smoking crack
    payload = [msg]
    while isinstance(payload, type([])):
        payload = payload[0].get_payload()
    return payload

def add_highlighted_str(win, terms, row, astr):
    win.move(row, 0)
    while astr:
        positions = ([(len(astr), 0)] +
                     [(astr.find(term), len(term))
                       for term in get_yes_terms(terms)
                       if term in astr])
        pos, hitsize = min(positions)
        normal = astr[:pos]
        highlighted = astr[pos:pos+hitsize]
        astr = astr[pos+hitsize:]
        try:
            win.addstr(normal, curses.color_pair(bodytext))
            win.addstr(highlighted, curses.color_pair(white))
        except:
            win.addstr(row, 0, 'ERROR')

def add_wrapped_str(win, row, width, astr, terms=''):
    cur_row = row
    while 1:
        front, astr = astr[:width], astr[width:]
        add_highlighted_str(win, terms, cur_row, front)
        cur_row += 1
        if not astr: break
        if cur_row >= curses.LINES-2: break
    return cur_row

def realname(addr):
    realname, email_address = email.Utils.parseaddr(addr)
    return realname or email_address

def joinlines(datum):
    return re.compile(r'\n\s+').sub(' ', datum)

def msgdate(msg):
    try:
        date = email.Utils.parsedate(joinlines(msg['date']))
        return time.strftime('%Y-%m-%d %H:%M', date)
    except:
        return "(couldn't parse date)"

def message_id(msg):
    return msg.get('message-id', 'spam without a message id').replace(' ', '-')

yellow = 1
white = 2
bodytext = 3

def adjwidth(astr, width):
    if len(astr) < width: return astr + ' ' * (width - len(astr))
    else: return astr[:width]

def draw_hdr_line(stdscr, msg, tagstore=None):
    name = realname(msg.get('from', '(no sender)')) + ' '
    date = ' ' + msgdate(msg)
    tags = ''
    remaining_space = curses.COLS - len(name) - len(date)
    if tagstore:
        tags = (' ' + ' '.join(tagstore[message_id(msg)]))[:remaining_space]
    subj = adjwidth(joinlines(msg.get('subject', '(no subject)')),
                    remaining_space - len(tags))
    stdscr.addstr(name, curses.color_pair(white) | curses.A_BOLD)
    stdscr.addstr(subj, curses.color_pair(yellow) | curses.A_BOLD)
    stdscr.addstr(tags, curses.color_pair(yellow))
    stdscr.addstr(date, curses.color_pair(white) | curses.A_BOLD)

# If you have a procedure with ten parameters, you probably missed some.
# -- Perlis
# (but I think it's true at five)
def redraw(stdscr, tags, msglist, msgnum, lineoffset, terms, view_source):
    msg = msglist[msgnum]
    if view_source: msgbody = msg.as_string()
    else: msgbody = body(msg)

    stdscr.bkgd(' ', curses.color_pair(bodytext))
    stdscr.clear()
    stdscr.attrset(curses.color_pair(yellow) | curses.A_BOLD)
    stdscr.addstr(0, 0, ' ' * curses.COLS)
    stdscr.addstr(1, 0, ' ' * curses.COLS)
    stdscr.addstr(0, 0, "[q]uit [n]ext [t]ag")
    if msgnum != 0: stdscr.addstr(" [p]revious")
    stdscr.addstr('  ' + ' '.join(tags[message_id(msg)]))

    stdscr.move(1, 0)
    draw_hdr_line(stdscr, msg)
    stdscr.attrset(curses.color_pair(bodytext))

    lines = iter(msglines(msgbody))
    for line in range(lineoffset): lines.next() # laaame
    row = 2
    while row < curses.LINES:
        row = add_wrapped_str(stdscr, row, curses.COLS, lines.next(), terms)

def toggle_spam_tag(tags, msg):
    msgid = message_id(msg)
    curtags = tags[msgid]
    if 'spam' in curtags:
        tags[msgid] = tuple([tag for tag in curtags if tag != 'spam'])
    else:
        tags[msgid] = tuple([tag for tag in curtags if tag != 'untagged'] +
                            ['spam'])

def tag_message(stdscr, tags, mboxlist, message_index):
    msgid = message_id(mboxlist[message_index])
    editwindow = stdscr.derwin(1, curses.COLS, 0, 0)
    editwindow.clear()
    if tags.has_key(msgid):
        editwindow.addstr(' '.join(tags[msgid]))
    textbox = curses.textpad.Textbox(editwindow)
    tags[msgid] = textbox.edit().split()

# This approach to searching will probably always be too slow.
def search_forward(tags, search_terms, mboxlist, message_index):
    try:
        newmi = message_index
        while 1:
            newmi += 1
            if message_matches(tags, search_terms, mboxlist[newmi]):
                return newmi
    except IndexError:
        return message_index
def search_backward(tags, search_terms, mboxlist, message_index):
    while message_index > 0:
        message_index -= 1
        if message_matches(tags, search_terms, mboxlist[message_index]):
            break
    return message_index
def last_message(mboxlist, message_index):
    try:
        while 1:
            mboxlist[message_index]
            message_index += 1
    except (IndexError, KeyboardInterrupt):
        return message_index - 1

def message_contains_term(msg, tags, term):
    if term.startswith('@'): return term[1:] in tags[message_id(msg)]
    elif term.startswith('s:'): return term[2:] in msg.get('subject', '')
    elif term.startswith('f:'): return term[2:] in msg.get('from', '')
    elif term.startswith('l:'):
        term = term[2:]
        return ((term in msg.get('sender', '')) or
                (term in msg.get('list-post', '')))
    elif term.startswith('t:'):
        term = term[2:]
        return ((term in msg.get('to', '')) or
                (term in msg.get('cc', '')))
    else: return term in msg.as_string()
def get_yes_terms(search_terms):
    terms = search_terms.split()
    return [term for term in terms if not term.startswith('-')]
def get_no_terms(search_terms):
    terms = search_terms.split()
    return [term[1:] for term in terms if term.startswith('-')]
def message_matches(tags, search_terms, msg):
    # This is too slow.
    for term in get_yes_terms(search_terms):
        if not message_contains_term(msg, tags, term): return False
    for term in get_no_terms(search_terms):
        if message_contains_term(msg, tags, term): return False
    return True
def set_search(stdscr, search_terms):
    editwindow = stdscr.derwin(1, curses.COLS, 0, 0)
    editwindow.clear()
    editwindow.addstr(search_terms)
    textbox = curses.textpad.Textbox(editwindow)
    return textbox.edit()
def flash_msg(stdscr, msg):
    stdscr.move(0, 0)
    stdscr.addstr(msg,
                  curses.color_pair(yellow) | curses.A_BLINK | curses.A_BOLD)
    stdscr.refresh()
def display_message_summary(stdscr, mboxlist, tags, search_terms, message_index):
    # This is painfully slow sometimes, depending on your
    # search.  Thus the .refresh().
    stdscr.clear()
    try:
        for ii in range(curses.LINES - 1):
            stdscr.move(ii, 0)
            draw_hdr_line(stdscr, mboxlist[message_index], tags)
            stdscr.refresh()
            next_mi = search_forward(tags, search_terms, mboxlist,
                                     message_index)
            if next_mi == message_index: break  # no more matches!
            message_index = next_mi
    except KeyboardInterrupt:
        pass

def write_to_file(file_to_write, msg):
    fp = file(file_to_write, 'ab')
    fp.write(msg.as_string())
    fp.close()

def realmain(stdscr, argv):
    tags = tagstore()
    mboxfilename = argv[1]
    mbox = file(mboxfilename)
    mboxobj = mbox
    mboxlist = MessageListFacade(mboxobj)
    cachefilename = mboxfilename + '.cached-summary.pck'
    mboxlist.read_cached_metadata(cachefilename)
    message_index = 0
    lineoffset = 0
    search_terms = ''
    file_to_write = 'tmp.mail'
    view_source = False
    viewing_summary = False

    cargo_cult_routine(stdscr)
    curses.init_pair(yellow, curses.COLOR_YELLOW, curses.COLOR_BLUE)
    curses.init_pair(white, curses.COLOR_WHITE, curses.COLOR_BLUE)
    curses.init_pair(bodytext, curses.COLOR_BLACK, curses.COLOR_WHITE)

    redraw(stdscr, tags, mboxlist, message_index, lineoffset, search_terms,
           view_source)
    while 1:
        ch = stdscr.getch()
        if ch == ord(' '):  # scroll down
            lineoffset += 4
        elif ch in (curses.KEY_DOWN, ord('n')):  # next message
            flash_msg(stdscr, "Sorry, searching...")
            try:
                message_index = search_forward(tags, search_terms, mboxlist,
                                               message_index)
                lineoffset = 0
            except KeyboardInterrupt:
                pass
        elif ch == ord('t'): # tag
            tag_message(stdscr, tags, mboxlist, message_index)
        elif message_index != 0 and ch in (curses.KEY_UP, ord('p')): # prev msg
            flash_msg(stdscr, "Sorry, searching...")
            try:
                message_index = search_backward(tags, search_terms, mboxlist,
                                                message_index)
                lineoffset = 0
            except KeyboardInterrupt:
                pass
        elif ch == ord('d'):  # debug
            stdscr.clear()
            items = mboxlist[message_index].cached_metadata.items()
            row = add_wrapped_str(stdscr, 0, curses.COLS,
                                  repr([(k, id(v), v) for k, v in items]))
            add_wrapped_str(stdscr, row, curses.COLS, repr(mboxlist.__dict__))
            continue
        elif ch == curses.KEY_LEFT:  # message summary
            viewing_summary = True
        elif ch in (curses.KEY_RIGHT, 10):  # view message
            viewing_summary = False
        elif ch in (curses.KEY_PPAGE, curses.KEY_NPAGE):  # pgup/pgdn summary
            flash_msg(stdscr, "Sorry, searching...")
            lineoffset = 0
            if ch == curses.KEY_PPAGE: search_direction = search_backward
            else: search_direction = search_forward
            try:
                for ii in range(curses.LINES-2):
                    new_mi = search_direction(tags, search_terms,
                                              mboxlist, message_index)
                    if new_mi == message_index: break
                    message_index = new_mi
            except KeyboardInterrupt: pass
            viewing_summary = True
        elif ch == ord('/'):  # search (not incremental, sadly)
            search_terms = set_search(stdscr, search_terms)
            if not message_matches(tags, search_terms, mboxlist[message_index]):
                flash_msg(stdscr, "Sorry, searching...")
                try:
                    message_index = search_forward(tags, search_terms, mboxlist,
                                                   message_index)
                    lineoffset = 0
                except KeyboardInterrupt:
                    pass
        elif ch == ord('\\'):
            view_source = not view_source
        elif ch == ord('q'): return  # quit
        elif ch == ord('s'):
            toggle_spam_tag(tags, mboxlist[message_index])
        elif ch == ord('>'):
            flash_msg(stdscr, "Reading to end of mailbox...")
            message_index = last_message(mboxlist, message_index)
            # Note that this still updates the cached state if the user hit ^C:
            flash_msg(stdscr, "Updating cached mailbox summary...")
            start = time.time()
            mboxlist.write_cached_metadata_to(cachefilename)
        elif ch == ord('f'):  # write to file, or forward
            flash_msg(stdscr, "Writing...")
            write_to_file(file_to_write, mboxlist[message_index])
            flash_msg(stdscr, "Written   ")
            continue  # to not erase the flashing message
        if viewing_summary:
            display_message_summary(stdscr, mboxlist, tags, search_terms,
                                    message_index)
        else:
            redraw(stdscr, tags, mboxlist, message_index, lineoffset,
                   search_terms, view_source)

def main(argv):
    cgitb.enable(format="text")
    curses.wrapper(lambda stdscr: realmain(stdscr, argv))
if __name__ == '__main__': main(sys.argv)


More information about the Kragen-hacks mailing list