You still probably don't want to use it, since it doesn't have any internal functions for sending or replying to email (leaving that to Emacs), it only supports local mbox files, and it assumes that existing messages in your mailbox never change.
I've cleaned it up a fair bit; the code should be more comprehensible and less disgusting now. Also: - it supports internationalization in headers and bodies (well, first parts) - slightly improved visual appearance - many operations, especially common ones, are dramatically faster - it can scroll backwards in messages now - it can jump forward and backward by subject - it no longer crashes if you hit ^C at the wrong time - new "count matches" command The next obvious thing to do is to make all the searching stuff use an on-disk inverted index (possibly just for headers at first) and rip out all this query caching stuff. This would make most operations fast instead of merely less slow, or slow less often. But that's hard, so I've been avoiding it. Even with all its defects, it's good enough that I've been using it in preference to less(1) full-time over nearly the last month. The detailed darcs changelog is below. Note that there was one hour in which I committed 9 separate patches. This is much easier to do with darcs than with CVS or Subversion, especially when you're not connected to the internet. Wed Oct 11 19:31:16 VET 2006 [EMAIL PROTECTED] * tolerate curses croaking on header lines Wed Oct 11 12:47:13 VET 2006 [EMAIL PROTECTED] * added primitive 'reply' function Tue Oct 10 13:44:17 VET 2006 [EMAIL PROTECTED] * renamed MessageProxy to EmailMessage Tue Oct 10 13:41:42 VET 2006 [EMAIL PROTECTED] * some comment updates Tue Oct 10 13:40:59 VET 2006 [EMAIL PROTECTED] * factored out scrolling from mainloop, added backward scrolling Mon Oct 9 17:50:02 VET 2006 [EMAIL PROTECTED] * refactored much of mainloop into a key_table Mon Oct 9 17:39:49 VET 2006 [EMAIL PROTECTED] * added forward and backward by subject Mon Oct 9 17:39:19 VET 2006 [EMAIL PROTECTED] * removed stdscr and mboxlist local variables in mainloop Mon Oct 9 17:38:47 VET 2006 [EMAIL PROTECTED] * refactored summary paging slightly Mon Oct 9 17:37:50 VET 2006 [EMAIL PROTECTED] * factored go_to_end and count_messages out of mainloop Mon Oct 9 17:37:10 VET 2006 [EMAIL PROTECTED] * made more_detail, less_detail reset to top of message Mon Oct 9 17:35:27 VET 2006 [EMAIL PROTECTED] * made search editing slightly easier Mon Oct 9 17:34:58 VET 2006 [EMAIL PROTECTED] * fix minor performance bug introduced in query refactoring Mon Oct 9 17:34:47 VET 2006 [EMAIL PROTECTED] * refactored queries into a bunch of SearchTerm objects The code is somewhat more readable, but definitely more verbose --- added nearly 40 lines of code, net. The idea is to better support things like smarter query caching (e.g. don't discard cache on tag change if the query doesn't depend on tags), smarter highlighting, quicker evaluation strategies, union queries, using indexes, etc. There are a bunch of small behavior changes from this "refactoring": - accidental occurrences of things like 't:kragen' in the email are no longer highlighted (nor is time spent searching for them) - now 't:' and 'l:' search in the decoded versions of their headers as well as the standard versions - now you can say "--foo", which means the same thing as "foo". Sun Oct 8 21:22:54 VET 2006 [EMAIL PROTECTED] * moved detail_level into summary_move_page Sun Oct 8 21:22:24 VET 2006 [EMAIL PROTECTED] * made background of message summary screen not bold Even non-bold text was appearing as bold as a result. Sun Oct 8 21:22:10 VET 2006 [EMAIL PROTECTED] * moved toggle_spam_tag into MessageBrowser Sun Oct 8 21:19:57 VET 2006 [EMAIL PROTECTED] * keep caching non-tag search results even if tags change Sun Oct 8 21:08:30 VET 2006 [EMAIL PROTECTED] * fixed small performance bug in query caching There was a problem that when searching backwards, if the search was unsuccessful and left you at the first mesage, the backward_cache would get updated, but the forward_cache wouldn't. Sun Oct 8 03:21:06 VET 2006 [EMAIL PROTECTED] * fixed summary page moves to use new detail_level Sun Oct 8 03:20:47 VET 2006 [EMAIL PROTECTED] * made background in overview view blue to diminish flashing Sun Oct 8 03:20:08 VET 2006 [EMAIL PROTECTED] * moved flash_msg into MailBrowser Sun Oct 8 00:31:15 VET 2006 [EMAIL PROTECTED] * replaced view_source and viewing_summary with a detail_level Sun Oct 8 00:30:16 VET 2006 [EMAIL PROTECTED] * fixed a bug that corrupted the tags file Sun Oct 8 00:05:00 VET 2006 [EMAIL PROTECTED] * trivial reformatting Sun Oct 8 00:04:19 VET 2006 [EMAIL PROTECTED] * factored out change_search() and summary_move_page() from mainloop Sat Oct 7 23:51:18 VET 2006 [EMAIL PROTECTED] * prevent unnecessary disk writes of cached metadata Sat Oct 7 23:50:30 VET 2006 [EMAIL PROTECTED] * updated comments and reformatted to 80 columns Sat Oct 7 23:42:10 VET 2006 [EMAIL PROTECTED] * moved tag_message into MailBrowser, factored out debug_dump() Sat Oct 7 23:16:01 VET 2006 [EMAIL PROTECTED] * moved display_message_summary into MailBrowser Sat Oct 7 23:06:16 VET 2006 [EMAIL PROTECTED] * refactored duplication out of go_forward and go_backward Sat Oct 7 22:54:46 VET 2006 [EMAIL PROTECTED] * added backward search, plus removed some duplication Sat Oct 7 22:54:11 VET 2006 [EMAIL PROTECTED] * removed obsolete Perlis comment Sat Oct 7 22:53:33 VET 2006 [EMAIL PROTECTED] * dramatically sped up going to the end of the file Sadly it still wants to rewrite the summary even if it's unchanged. Sat Oct 7 22:36:57 VET 2006 [EMAIL PROTECTED] * fixed rather serious performance bug Previously if a header was requested that didn't exist in the message, even though the summary told us that, we would fetch the message off the disk anyway. This was a big problem for e.g. mailing list membership queries. Sat Oct 7 22:19:23 VET 2006 [EMAIL PROTECTED] * split Query into Query and View Sat Oct 7 21:48:36 VET 2006 [EMAIL PROTECTED] * cached query results and simplified query interface Now things display quickly. Sat Oct 7 21:22:45 VET 2006 [EMAIL PROTECTED] * queries access the tag store as an instance variable now Previously they accessed them as parameters. Sat Oct 7 21:11:52 VET 2006 [EMAIL PROTECTED] * simplified interface to MessageListFacade by removing fp Sat Oct 7 21:08:36 VET 2006 [EMAIL PROTECTED] * moved cachefilename into MessageListFacade class Sat Oct 7 20:58:18 VET 2006 [EMAIL PROTECTED] * moved tags, query, and redraw into MailBrowser Now, of redraw()'s parameters, only view_source remains as a local variable. Sat Oct 7 20:45:09 VET 2006 [EMAIL PROTECTED] * finished moving query stuff into new Query object Now the only time anything outside of Query touches search_terms is when the user is editing them. Sat Oct 7 20:40:54 VET 2006 [EMAIL PROTECTED] * continued moving search term handling into new Query object Sat Oct 7 20:32:38 VET 2006 [EMAIL PROTECTED] * began moving search term handling into new Query object Sat Oct 7 20:20:27 VET 2006 [EMAIL PROTECTED] * moved mboxlist, current_message, and line_offset into instance variables Also eliminated local variables mbox and mboxobj. At last we begin to eliminate some code duplication! Sat Oct 7 20:08:57 VET 2006 [EMAIL PROTECTED] * turned local current_message into instance variable Sat Oct 7 20:04:45 VET 2006 [EMAIL PROTECTED] * removed message_index local variable, made it an instance variable Sat Oct 7 20:04:29 VET 2006 [EMAIL PROTECTED] * fixed bug with malformed header handling Sat Oct 7 20:04:05 VET 2006 [EMAIL PROTECTED] * moved list-headers comments block to more relevant place Sat Oct 7 19:47:50 VET 2006 [EMAIL PROTECTED] * moved realmain() into a new MailBrowser object Sat Oct 7 19:41:27 VET 2006 [EMAIL PROTECTED] * recording failed attempt to marshal Sat Oct 7 19:18:30 VET 2006 [EMAIL PROTECTED] * sped up scrolling dramatically in large message Added more code duplication to realmain(). But now the message object itself has its lines, so we don't have to resplit it for each screen update. Sat Oct 7 19:13:29 VET 2006 [EMAIL PROTECTED] * added charset support for headers and bodies Sat Oct 7 19:12:45 VET 2006 [EMAIL PROTECTED] * added 'count matches' command Sat Oct 7 19:12:21 VET 2006 [EMAIL PROTECTED] * fixed tiny header parsing bug that showed up in some spam #!/usr/bin/python import curses, time, cgitb, sys, email, mailbox, re, os, curses.textpad import cPickle, quopri, base64, email.Utils # Embarrassingly ugly and fairly minimal mail reader. # TODO: # - CLEAN UP MESSY AND DUPLICATED CODE # D 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? (usually don't now) # D remove duplication among many redraw() calls # - make it possible to display multiple messages # D handle IndexError without crashing # - handle other exceptions without crashing # D cache message summary data on disk # - still not good enough and not an unqualified win # - adjust to screen resizing # - probably not possible without modifying Python curses binding to # support resizeterm(3NCURSES). Maybe use ctypes? # - how does Urwid do it? (at first glance, it looks like it looks for 410 KEY_RESIZE) # - 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 # D 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 ([EMAIL PROTECTED] 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() # 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 def mintern(obj): try: return intern(obj) except TypeError: return obj def joinlines(datum): return re.compile(r'\n\s+').sub(' ', datum) qre = re.compile('''(?ix)= \?(?P<charset>[^?]*) \?(?P<encoding>[bq]) \?(?P<content>.*?) \?=''') def decode_header_for_display(headerstring): def decode_chunk(mo): try: if mo.group('encoding').lower() == 'q': encoding_scheme = quopri else: encoding_scheme = base64 orig_string = encoding_scheme.decodestring(mo.group('content')) return unicode(orig_string, mo.group('charset')).encode('utf-8') except (LookupError, UnicodeDecodeError, base64.binascii.Error): return mo.group(0) return qre.sub(decode_chunk, headerstring) 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 '' def __iter__(self): return iter(self.lines) # 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 # [EMAIL PROTECTED] [EMAIL PROTECTED] Mailman # [EMAIL PROTECTED] beowulf@beowulf.org Mailman # [EMAIL PROTECTED] [EMAIL PROTECTED] Majordomo # [EMAIL PROTECTED] [EMAIL PROTECTED] Yahoo Groups # Vanagon Mailing List <[EMAIL PROTECTED]> [EMAIL PROTECTED] Listserv # [EMAIL PROTECTED] [EMAIL PROTECTED] 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. class EmailMessage: # 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 = {} self._as_string_lines = None self._utf8_body_lines = None def __repr__(self): return '<EmailMessage %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 key in self.cached_metadata: rv = self.cached_metadata[key] if rv is None: return default else: return rv elif key not in self.keys: raise KeyError, key return mintern(self.fastparse().get(key, default)) def get_readable(self, key, default): """Return a readable string representation of the header contents.""" return decode_header_for_display(joinlines(self.get(key, default))) def get_slow(self, key, default): return 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 utf8_body(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. # Whoever was designing the email.Message API was smoking # crack. get_payload returns either a string, or a list, # except that it might return None, if it would have returned # a list except that you specified decode=True. So we end up # getting the same payload twice. msgobj = self.msg() while 1: discriminator = msgobj.get_payload() if not isinstance(discriminator, type([])): payload = msgobj.get_payload(decode=True) charset = msgobj.get_content_charset() or 'utf-8' if charset == 'us-ascii': charset = 'utf-8' # safer & compat. if charset == 'utf-8': return payload try: return unicode(payload, charset).encode('utf-8') except (LookupError, UnicodeDecodeError): return payload else: msgobj = msgobj.get_payload(0) def utf8_body_lines(self): if not self._utf8_body_lines: self._utf8_body_lines = msglines(self.utf8_body()) return self._utf8_body_lines def as_string_lines(self): if not self._as_string_lines: self._as_string_lines = msglines(self.as_string()) return self._as_string_lines # 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 def reply_skeleton(msg, me): hdrs = ['From: %s\n' % me, 'MIME-Version: 1.0\n', 'Content-Type: text/plain; charset=utf-8\n', 'Content-Transfer-Encoding: 8bit\n'] reply_to = msg.get_slow('reply-to', None) or msg.get('from', None) hdrs.append('To: %s\n' % reply_to) to_addr = msg.get('to', '') cc_addr = msg.get('cc', '') if to_addr and cc_addr: dont_cc = to_addr + ', ' + cc_addr else: dont_cc = to_addr or cc_addr hdrs.append('Dont-Cc: %s\n' % dont_cc) subject = msg.get('subject', 'Your mail') if 're:' not in subject.lower(): subject = 'Re: ' + subject hdrs.append('Subject: %s\n' % subject) msg_id = msg.get('message-id', None) if msg_id: hdrs.append('In-Reply-To: %s\n' % msg_id) references = msg.get_slow('references', '') if msg_id: references += '\n\t%s' % msg_id hdrs.append('References: %s\n' % references) date = msg.get('date', 'an unknown date') name = realname(msg.get('from', 'an unknown person')) citation_line = 'On %s, %s wrote:\n' % (date, name) return ''.join(hdrs + ['\n', citation_line] + ['> %s\n' % line for line in msg.utf8_body_lines()] + ['\n']) class MessageListFacade: def __init__(self, mboxfilename): self.fp = file(mboxfilename) # We don't really care what kind of objects self.mbox.next() # returns, as long as they aren't None. self.mbox = SeekableUnixMailbox(self.fp, lambda subfile: subfile) self.msgs = [self.mbox.tell()] self.metadata = [] self.cachefilename = mboxfilename + '.cached-summary.pck' self.dirty_bit = False def last_known_message(self): return len(self.msgs) - 1 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) self.dirty_bit = True # This puts the *end* offset 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 = EmailMessage(subfile) while len(self.metadata) <= index: self.metadata.append(None) if not self.metadata[index]: self.dirty_bit = True self.metadata[index] = tuple(map(rv.get, rv.keys)) rv.cached_metadata_is(self.metadata[index]) return rv # I tried using marshal instead of cPickle to save the strings and # numbers that constitute the summary. With marshal, saving cache # takes 1.3 seconds and 17MB; loading takes 1.8. With cPickle, # saving cache takes 2.0 seconds and 16MB; loading takes 1.9. The # difference is detectable but not worthwhile. def write_cached_metadata(self): if not self.dirty_bit: return try: newfilename = self.cachefilename + '.new' outfile = file(newfilename, 'w') cPickle.dump(self.metadata, outfile, 2) cPickle.dump(self.msgs, outfile, 2) outfile.close() os.rename(newfilename, self.cachefilename) self.dirty_bit = False except KeyboardInterrupt: pass def read_cached_metadata(self): try: infile = file(self.cachefilename) except IOError: return self.metadata = cPickle.load(infile) self.msgs = cPickle.load(infile) self.dirty_bit = False # XXX need to validate the metadata! 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 = {} self.update_count = 0 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) self.update_count += 1 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() class View: def __init__(self, search_terms, tags, mboxlist): self.query = Query(search_terms) self.mboxlist = mboxlist self.tags = tags # tag store self.clear_caches() def clear_caches(self): self.forward_cache = {} self.backward_cache = {} self.last_tag_update = self.tags.update_count def caches_outdated(self): """Probably this is the wrong idea, but we cache search results. It's the wrong idea because it's probably possible to make search results reliably fast and get rid of all the caching logic. However, in the mean time, this method tells other methods in this object whether the cache is invalid. """ return (self.last_tag_update != self.tags.update_count and self.query.depends_on_tags()) # This approach to searching will probably always be too slow. def search_forward(self, message_index): if self.caches_outdated(): self.clear_caches() if message_index not in self.forward_cache: try: newmi = message_index while 1: newmi += 1 if newmi in self: self.forward_cache[message_index] = newmi if message_index in self: self.backward_cache[newmi] = message_index break except IndexError: self.forward_cache[message_index] = message_index return self.forward_cache[message_index] def search_backward(self, message_index): if self.caches_outdated(): self.clear_caches() if message_index not in self.backward_cache: newmi = message_index while newmi > 0: newmi -= 1 if newmi in self: break if message_index in self and newmi < message_index: self.forward_cache[newmi] = message_index self.backward_cache[message_index] = newmi return self.backward_cache[message_index] def __contains__(self, message_index): return self.query.message_matches(self.mboxlist[message_index], self.tags) def any(iterable_of_booleans): for each in iterable_of_booleans: if each: return True return False def all(iterable_of_booleans): return not any(not each for each in iterable_of_booleans) class SearchTerm: """Null search term that always matches. Ancestor of all search terms.""" def __init__(self, term): self.term = term def position_in_string(self, astr): """For highlighting matches: return (position, length).""" return (len(astr), 0) def depends_on_tags(self): return False def matches(self, msg, tags): return True class TagTerm(SearchTerm): def depends_on_tags(self): return True def matches(self, msg, tags): return self.term in tags[message_id(msg)] class HeaderTerm(SearchTerm): def matches(self, msg, tags): return (self.term in msg.get(self.header, '') or self.term in msg.get_readable(self.header, '')) class SubjectTerm(HeaderTerm): header = 'subject' class FromTerm(HeaderTerm): header = 'from' class ListNameTerm(SearchTerm): class SenderTerm(HeaderTerm): header = 'sender' class ListPostTerm(HeaderTerm): header = 'list-post' def __init__(self, term): self.kids = [self.SenderTerm(term), self.ListPostTerm(term)] def matches(self, msg, tags): return any(kid.matches(msg, tags) for kid in self.kids) class ToTerm(ListNameTerm): class CcTerm(HeaderTerm): header = 'cc' class ToTerm(HeaderTerm): header = 'to' def __init__(self, term): self.kids = [self.CcTerm(term), self.ToTerm(term)] class WholeMessageTerm(SearchTerm): def matches(self, msg, tags): return self.term in msg.as_string() def position_in_string(self, astr): rv = astr.find(self.term) if rv == -1: return SearchTerm.position_in_string(self, astr) return rv, len(self.term) class NotTerm(SearchTerm): def __init__(self, term): self.kid = term_factory(term) def matches(self, msg, tags): return not self.kid.matches(msg, tags) def depends_on_tags(self): return self.kid.depends_on_tags() term_prefix_table = [ ('-', NotTerm), ('@', TagTerm), ('s:', SubjectTerm), ('f:', FromTerm), ('l:', ListNameTerm), ('t:', ToTerm), ('', WholeMessageTerm), ] def term_factory(term): for prefix, termtype in term_prefix_table: if term.startswith(prefix): return termtype(term[len(prefix):]) assert 0, term class Query: def __init__(self, search_terms): self.search_terms = search_terms # Ensure it's never empty: self.term_objects = ([SearchTerm('')] + map(term_factory, search_terms.split())) def depends_on_tags(self): return any(term.depends_on_tags() for term in self.term_objects) def message_matches(self, msg, tags): # This is too slow. return all(term.matches(msg, tags) for term in self.term_objects) def add_highlighted_str(win, query, row, astr): win.move(row, 0) while astr: pos, hitsize = min([term.position_in_string(astr) for term in query.term_objects]) 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, query=Query('')): cur_row = row while 1: front, astr = astr[:width], astr[width:] add_highlighted_str(win, query, 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 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 joinlines(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_readable('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(msg.get_readable('subject', '(no subject)'), remaining_space - len(tags)) try: 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) except: stdscr.addstr('error displaying header line', curses.color_pair(yellow) | curses.A_BOLD) def last_message(mboxlist): message_index = mboxlist.last_known_message() try: while 1: mboxlist[message_index] message_index += 1 except (IndexError, KeyboardInterrupt): return message_index - 1 def set_search(stdscr, search_terms): editwindow = stdscr.derwin(1, curses.COLS, 0, 0) editwindow.clear() if search_terms: search_terms += ' ' editwindow.addstr(search_terms) textbox = curses.textpad.Textbox(editwindow) return textbox.edit().strip() def write_to_file(file_to_write, msg): fp = file(file_to_write, 'ab') fp.write(msg) fp.close() def subject_terms(message): return ' '.join('s:' + term for term in message.get_readable('subject', '').split() if term.lower() != 're:') class MailBrowser: def __init__(self, stdscr, mboxfilename): self.stdscr = stdscr self.mboxlist = MessageListFacade(mboxfilename) self.mboxlist.read_cached_metadata() self.go_to(0) self.tags = tagstore() self.view = View('', self.tags, self.mboxlist) self.detail_levels = [self.display_message_summary, self.display_message_body, self.display_message_source] self.detail_level = 1 def go_to(self, new_message_index): # XXX a KeyboardInterrupt in this routine could cause bad behavior self.message_index = new_message_index self.current_message = self.mboxlist[self.message_index] self.lineoffset = 0 def flash_msg(self, msg): self.stdscr.move(0, 0) flashing = curses.color_pair(yellow) | curses.A_BLINK | curses.A_BOLD self.stdscr.addstr(msg, flashing) self.stdscr.refresh() def more_detail(self): if self.detail_level < len(self.detail_levels) - 1: self.detail_level += 1 self.lineoffset = 0 def less_detail(self): if self.detail_level > 0: self.detail_level -= 1 self.lineoffset = 0 def display_message_body(self): self.redraw(self.current_message.utf8_body_lines()) def display_message_source(self): self.redraw(self.current_message.as_string_lines()) def redraw(self, lines): self.stdscr.bkgd(' ', curses.color_pair(bodytext)) self.stdscr.clear() self.stdscr.attrset(curses.color_pair(yellow) | curses.A_BOLD) self.stdscr.addstr(0, 0, ' ' * curses.COLS) self.stdscr.addstr(1, 0, ' ' * curses.COLS) self.stdscr.addstr(0, 0, "[q]uit [n]ext [t]ag") if self.message_index != 0: self.stdscr.addstr(" [p]revious") self.stdscr.addstr(' ' + ' '.join(self.tags[ message_id(self.current_message)])) self.stdscr.move(1, 0) draw_hdr_line(self.stdscr, self.current_message) self.stdscr.attrset(curses.color_pair(bodytext)) row = 2 lineoffset = self.lineoffset while row < curses.LINES: line = lines[lineoffset] row = add_wrapped_str(self.stdscr, row, curses.COLS, line, self.view.query) lineoffset += 1 def display_message_summary(self): # This is painfully slow sometimes, depending on your # search. Thus the .refresh(). self.stdscr.bkgd(' ', curses.color_pair(white)) self.stdscr.clear() message_index = self.message_index try: for ii in range(curses.LINES - 1): self.stdscr.move(ii, 0) draw_hdr_line(self.stdscr, self.mboxlist[message_index], self.tags) self.stdscr.refresh() next_mi = self.view.search_forward(message_index) if next_mi == message_index: break # no more matches! message_index = next_mi except KeyboardInterrupt: pass def toggle_spam_tag(self): msgid = message_id(self.current_message) curtags = self.tags[msgid] if 'spam' in curtags: self.tags[msgid] = tuple([tag for tag in curtags if tag != 'spam']) else: self.tags[msgid] = tuple([tag for tag in curtags if tag != 'untagged'] + ['spam']) def tag_message(self): msgid = message_id(self.current_message) editwindow = self.stdscr.derwin(1, curses.COLS, 0, 0) editwindow.clear() if self.tags.has_key(msgid): editwindow.addstr(' '.join(self.tags[msgid])) textbox = curses.textpad.Textbox(editwindow) self.tags[msgid] = textbox.edit().split() def change_search(self): self.view = View( set_search(self.stdscr, self.view.query.search_terms), self.tags, self.mboxlist) def debug_dump(self): self.stdscr.clear() items = self.current_message.cached_metadata.items() row = add_wrapped_str(self.stdscr, 0, curses.COLS, repr([(k, id(v), v) for k, v in items])) add_wrapped_str(self.stdscr, row, curses.COLS, repr(self.mboxlist.__dict__)) def count_messages(self): self.flash_msg("Sorry, counting...") try: tmp_mi = -1 count = 0 while 1: new_mi = self.view.search_forward(tmp_mi) if new_mi == tmp_mi: break count += 1 tmp_mi = new_mi self.flash_msg('%d messages match ' % count) except KeyboardInterrupt: return False # didn't count return True # did count def summary_move_page(self, search_direction): self.flash_msg("Sorry, searching...") try: for ii in range(curses.LINES-2): new_mi = search_direction(self.message_index) if new_mi == self.message_index: break self.go_to(new_mi) except KeyboardInterrupt: pass self.detail_level = 0 def go_to_end(self): self.flash_msg("Reading to end of mailbox...") self.go_to(last_message(self.mboxlist)) # Note that this still updates the cache if the user hit ^C: self.flash_msg("Updating cached mailbox summary...") start = time.time() self.mboxlist.write_cached_metadata() #self.flash_msg("Updating cache took %0.3f seconds" % # (time.time() - start)) #continue def go_forward(self): self.go(self.view.search_forward) def go_backward(self): self.go(self.view.search_backward) def go(self, what_direction): self.flash_msg("Sorry, searching...") try: self.go_to(what_direction(self.message_index)) except KeyboardInterrupt: pass def scroll(self, howmuch): self.lineoffset += howmuch if self.lineoffset < 0: self.lineoffset = 0 def same_subject_view(self): return View(subject_terms(self.current_message), self.tags, self.mboxlist) def write_reply(self): self.flash_msg('writing reply...') me = 'Kragen Javier Sitaker <[EMAIL PROTECTED]>' reply = ('~m~r\n' + reply_skeleton(self.current_message, me).replace('~', '~t') + '~e\n') write_to_file('tmp.replies', reply) msgid = message_id(self.current_message) curtags = self.tags[msgid] if 'replied' not in curtags: self.tags[msgid] = tuple([tag for tag in curtags if tag != 'toreply']) + ('replied',) def mainloop(self): file_to_write = 'tmp.mail' cargo_cult_routine(self.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) key_table = { curses.KEY_DOWN: self.go_forward, 'n': self.go_forward, curses.KEY_UP: self.go_backward, 'p': self.go_backward, ' ': lambda: self.scroll(+4), curses.KEY_BACKSPACE: lambda: self.scroll(-4), 8: lambda: self.scroll(-4), '[': lambda: self.go(self.same_subject_view().search_backward), ']': lambda: self.go(self.same_subject_view().search_forward), 't': self.tag_message, curses.KEY_LEFT: self.less_detail, curses.KEY_RIGHT: self.more_detail, '\n': self.more_detail, curses.KEY_PPAGE: lambda: self.summary_move_page(self.view.search_backward), curses.KEY_NPAGE: lambda: self.summary_move_page(self.view.search_forward), 's': self.toggle_spam_tag, '>': self.go_to_end, 'r': self.write_reply, } for key, val in key_table.items(): if not isinstance(key, type(0)): key_table[ord(key)] = val self.detail_levels[self.detail_level]() while 1: try: ch = self.stdscr.getch() if ch in key_table: key_table[ch]() elif ch == -1: pass elif ch == ord('d'): # debug self.debug_dump() continue elif ch == ord('/'): # search (not incremental, sadly) self.change_search() if self.message_index not in self.view: self.go_forward() elif ch == ord('?'): # search backwards self.change_search() self.go_backward() elif ch == ord('q'): return # quit elif ch == ord('#'): # 'continue' to avoid erasing message count if self.count_messages(): continue elif ch == ord('f'): # write to file, or forward self.flash_msg("Writing...") write_to_file(file_to_write, self.current_message.as_string()) self.flash_msg("Written ") continue # to not erase the flashing message except KeyboardInterrupt: pass self.detail_levels[self.detail_level]() def realmain(stdscr, argv): MailBrowser(stdscr, argv[1]).mainloop() def main(argv): cgitb.enable(format="text") curses.wrapper(lambda stdscr: realmain(stdscr, argv)) if __name__ == '__main__': main(sys.argv) -- Kragen Javier Sitaker in Caracas, trying to get a clue