#!/usr/bin/python # -*- coding: utf-8 -*- """Electronic mail for the internet technologist. UI modeled after the highly successful Fecebutt chat: a pane on the left listing conversational threads (listing partners and latest text in each), a pane on the right with the current conversation, chronologically ordered with an editing area on the bottom. So far this is a crude prototype. Possible ways to improve it include: * handling queued parse messages during idle time instead of during redraw * minimal searching - ordering threads by recency - caching recent thread info in a poo-file - how about some gradients? Or graphics? - supporting replying - using a real RFC-822 parser with MIME support - parsing the mailbox from the end instead of the start (especially useful for multi-gigabyte mailboxes; it takes 90" to read my current quarter-gig mbox) - storing some parse data in the fs - tagging and tag querying - displaying multiple mail messages instead of one in a thread - supporting Unicode instead of whatever crap Qt supports by default (maybe Latin-1?) - including both sent and received mail - splitting into threads based on subjects and maybe references instead of just sender - hiding full headers and actually all headers by default - rewriting in OCaml - maybe rewriting with some other graphics library """ import os import Queue import re import sys import thread import time import PyQt4.QtGui import PyQt4.QtCore def main(argv): mbox = argv[1] queue = Queue.Queue() launch_parser(mbox, queue) while queue.qsize() == 0: time.sleep(0.05) app = PyQt4.QtGui.QApplication(argv) window = Window(queue, open(mbox)) # Thanks, pokey909! http://stackoverflow.com/a/6541995 window.showFullScreen() window.poll_queue() return app.exec_() class Window(PyQt4.QtGui.QWidget): def __init__(self, queue, mbox_file): super(Window, self).__init__() self.setLayout(PyQt4.QtGui.QGridLayout()) self.setWindowTitle("emit") self.setMinimumSize(128, 128) self.mail_queue = queue self.mbox_file = mbox_file self.index = 0 self.message = '' self.scrolloff = 0 self.threads = [] self.thread_indices = {} self.font_size = 10 self.dirty = False self.editing = '' self.search = '' self.page_size = 10 def paintEvent(self, event): self.dirty = False painter = PyQt4.QtGui.QPainter(self) self.clear((0, 0), painter) font_height = self.font_size font = PyQt4.QtGui.QFont("Times", font_height) painter.setFont(font) red_pen = PyQt4.QtGui.QPen(PyQt4.QtGui.QColor('#f52')) painter.setPen(red_pen) left_column = Column(painter, (3, font_height * 2), font_height * 1.5, self.height()) top_line = u'%d/%d %s cabrón ☺ %s' % (self.index, len(self.threads), self.message, self.search) try: left_column.println(top_line) for ii, thread in enumerate(self.threads): if ii < self.index - 5: continue if ii == self.index: painter.setPen(PyQt4.QtGui.QPen(PyQt4.QtGui.QColor('#fc7'))) try: if self.match(thread): left_column.println(thread['from'] or '(no from)') left_column.println(thread['snippet'] or '(no conversation yet)') left_column.println('') finally: if ii == self.index: painter.setPen(red_pen) ii += 1 except ColumnFull: pass xx = max(256, min(128, self.width() / 2)) self.clear((xx, 0), painter) xx += 3 right_column = Column(painter, (xx+3, font_height * 2), font_height * 1.5, self.height()) if self.index >= len(self.threads): return thread = self.threads[self.index] big_font = PyQt4.QtGui.QFont("Times", font_height * 1.5) painter.setFont(big_font) try: right_column.println(thread['from'], line_height=1.5) right_column.println(self.editing) right_column.println('') finally: painter.setFont(font) try: scrolled_lines = 0 for offset in thread['offsets']: self.mbox_file.seek(offset) for lineno, line in enumerate(self.mbox_file): if line.startswith('From ') and lineno != 0: break while line.endswith('\n'): line = line[:-1] if scrolled_lines >= self.scrolloff: right_column.println(line) scrolled_lines += 1 except ColumnFull: self.page_size = scrolled_lines - self.scrolloff - 1 pass def match(self, thread): f = thread['from'] or '' s = thread['snippet'] or '' if self.search == self.search.lower(): f = f.lower() s = s.lower() return self.search in f or self.search in s def keyPressEvent(self, event): Qt = PyQt4.QtCore.Qt modifiers = int(event.modifiers()) ctrl = not not (modifiers & Qt.ControlModifier) alt = not not (modifiers & Qt.AltModifier) key = event.key() if key == Qt.Key_Up: while self.index > 0: self.index -= 1 if (self.index < len(self.threads) and self.match(self.threads[self.index])): break self.scrolloff = 0 self.message = '' self.update() elif key == Qt.Key_Down: while self.index < len(self.threads) - 1: self.index += 1 if self.match(self.threads[self.index]): break self.scrolloff = 0 self.message = '' self.update() elif key == Qt.Key_Space and ctrl: self.scrolloff += self.page_size self.update() elif key == Qt.Key_Backspace and ctrl: self.scrolloff -= self.page_size if self.scrolloff < 0: self.scrolloff = 0 self.update() elif key == Qt.Key_Plus and ctrl: self.font_size += 2.5 self.update() elif key == Qt.Key_Minus and ctrl: if self.font_size > 2.5: self.font_size -= 2.5 self.update() elif key == Qt.Key_Backspace: self.editing = self.editing[:-1] self.update() elif not ctrl and not alt and str(event.text()): self.editing += str(event.text()) self.update() elif ctrl and key == Qt.Key_Question: self.search = self.editing self.editing = '' self.update() def poll_queue(self): try: msg = self.mail_queue.get_nowait() thread_id = msg.get('from') if thread_id not in self.thread_indices: self.threads.append({'from': msg.get('from')}) self.thread_indices[thread_id] = len(self.threads) - 1 snippet = msg.get('body') or msg.get('subject') thread = self.threads[self.thread_indices[thread_id]] thread['snippet'] = snippet if 'offsets' not in thread: thread['offsets'] = [] thread['offsets'].append(msg['start']) self.dirty = True except Queue.Empty: PyQt4.QtCore.QTimer.singleShot(31, self.poll_queue) if self.dirty: self.update() return PyQt4.QtCore.QTimer.singleShot(0, self.poll_queue) def clear(self, (xx, yy), painter): """Doesn’t really work.""" background = PyQt4.QtGui.QPainterPath() background.moveTo(xx, yy) background.lineTo(self.width(), yy) background.lineTo(self.width(), self.height()) background.lineTo(xx, self.height()) background.closeSubpath() painter.setBrush(PyQt4.QtGui.QBrush(PyQt4.QtGui.QColor('black'))) painter.drawPath(background) class Column: def __init__(self, painter, (xx, yy), line_height, bottom): self.painter = painter (self.xx, self.yy) = (xx, yy) self.line_height = line_height self.bottom = bottom def println(self, line, line_height=1): if self.yy > self.bottom + self.line_height * line_height: raise ColumnFull() self.painter.drawText(point(self.xx, self.yy), line) self.yy += self.line_height * line_height class ColumnFull(Exception): pass point = PyQt4.QtCore.QPoint def launch_parser(filename, queue): def parser(): fo = open(filename) state = Body current_message = None current_header = None current_body = '' byte_offset = 0 current_message_start = 0 def finish_message(): if current_message is None: return current_message['body'] = whitespace.sub(' ', current_body) queue.put(current_message) def finish_header(header): if header is not None: key, value = header.split(':', 1) kl = key.lower() if kl in 'from to subject'.split(): current_message[kl] = value.strip() for line in fo: last_byte_offset = byte_offset byte_offset += len(line) if line.startswith('From '): finish_message() state = Headers current_message = {'start': last_byte_offset} current_header = None current_body = '' elif state is Headers: if line == '\n': finish_header(current_header) current_header = None state = Body if line[0] in '\t ': if current_header is not None: current_header += line else: finish_header(current_header) current_header = line elif state is Body: if len(current_body) < body_snippet_length: current_body += line current_body = current_body[:body_snippet_length] finish_message() thread.start_new_thread(parser, ()) Body = object() Headers = object() body_snippet_length = 80 whitespace = re.compile(r'\s+') if __name__ == '__main__': sys.exit(main(sys.argv))