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] [email protected]
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