#!/usr/bin/python3 # -*- coding: utf-8 -*- """Qyap: Display and reply to mail messages with Qt5 and LevelDB. See levelmail.py for details on the database setup. """ # Bugs/notes: # - You can’t use F to move back to a msgno with a smaller number of # digits, because it’s out of order in the db. # - The interface to `find` ought to accommodate a starting msgno and # forward/backward. # - Group reply and Cc display are pretty important missing items. # - I really want some kind of tagging and spam filtering. # - Word wrap — and, perhaps as important, line length # limitation — are needed. Keith keeps posting these mails with # paragraph-long lines. # - Maybe group reply should be the default? I mean you can always # delete the extra recipients. # - MIME decoding is also needed, especially for replying, for # subjects, and for Base64 bodies. # - Maybe instead of storing chunks of the body, I should store chunks # of the message, including the headers. That would make headers # display easy to implement. Then maybe the noise headers could be # hidden by making the text small and light instead of not showing # them at all. # - I need to use Delivered-To: to figure out what email address to # reply from, which is a problem because the one that gets preserved # is the wrong one when there is more than one (as in PyAr # messages.) So I need to rethink EAV, or use a different structure. # - Maybe I should gzip the chunks I store. One sample chunk turned # out to be 909 bytes gzipped rather than 2056 uncompressed, so # maybe I could get 44% compression of the whole mail database, if # it’s mostly full of message bodies instead of headers, as may be. # - Displaying more than one message at a time — ideally in threads, # normally — is also pretty important. # - Is the name `qmsg` taken? Yes, it’s a PBS utility. `qyap` isn’t. # - I need a way to import new messages from the server. import argparse import datetime import hashlib import math import os import sys import time import leveldb from PyQt5 import QtGui, Qt, QtWidgets, QtCore def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument('db', type=filename, help="path to the LevelDB containing the mail") p.add_argument('msgno', type=int, nargs='?', default=1, help="number of the message to display initially") args = p.parse_args() app = QtWidgets.QApplication(sys.argv) window = Window(Mail(args.db), initial_msg=args.msgno) window.show() return app.exec_() def filename(s): if not os.path.exists(s): raise ValueError(s) return s class Mail: def __init__(self, fname): self.db = leveldb.LevelDB(fname, create_if_missing=False) def get_message(self, msgno): return Message(self, msgno) def get_header(self, msgno, header_name): k = 'eav {} {}:'.format(msgno, header_name.lower()).encode('utf-8') return self.db.Get(k).decode('utf-8', errors='replace') def get_body(self, msgno): i = 0 while True: si = str(i) k = 'eav {} {}:{}'.format(msgno, len(si), si).encode('utf-8') try: yield self.db.Get(k).decode('utf-8', errors='replace') except KeyError: return i += 1 def find(self, criteria): k1, = criteria # XXX only handles one key! v1 = criteria[k1] k2 = 'ave {}: {} '.format(k1, v1).encode('utf-8') for k, _ in self.db.RangeIter(k2): if not k.startswith(k2): break yield int(k.split()[-1]) class Message: def __init__(self, db, msgno): self.db = db self.msgno = msgno def __getitem__(self, header_name): return self.db.get_header(self.msgno, header_name) def get(self, k, default=None): try: return self[k] except KeyError: return default def __contains__(self, key): x = object() return x is not self.get(key, default=x) @property def body(self): return self.db.get_body(self.msgno) @property def lines(self): remains = '' for chunk in self.body: lines = chunk.splitlines(keepends=True) for line in lines: if line.endswith('\n'): yield remains + line remains = '' if lines and not line.endswith('\n'): remains = line if remains: yield remains class Window(Qt.QWidget): def __init__(self, maildb, initial_msg): super(Window, self).__init__() self.db = maildb self.msgno = initial_msg self.fs = 12 self.scroll_pos = 0 self.screen_lines = 32 self.setLayout(Qt.QGridLayout()) self.setWindowTitle('qyap') self.setMinimumSize(256, 256) self.show_message() def show_message(self): self.repaint() def paintEvent(self, event): painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.Antialiasing) painter.fillRect(self.rect(), QtCore.Qt.white) painter.setPen(QtGui.QColor(64, 64, 64)) lm = 3 # left margin tm = 3 # top margin fs = self.fs # font size ts = math.ceil(self.fs * 2**.5) # title size ld = math.ceil(fs/5) # leading painter.setFont(QtGui.QFont("NotoSans", ts)) painter.drawText(lm, tm+ts, "#{}".format(self.msgno)) msg = self.current() painter.drawText(lm+8*ts, tm+ts, msg.get('subject', '(no subject)')) painter.drawText(lm, tm+2*ts+ld, msg.get('from', '(no sender)')) painter.drawText(lm, tm+3*ts+2*ld, msg.get('date', '(no date)')) painter.setFont(QtGui.QFont("NotoSans", fs)) y = tm+3*ts+2*fs+4*ld lineno = 0 for lineno, line in enumerate(msg.lines): if lineno < self.scroll_pos: continue painter.drawText(lm*2, y, line) y += fs+ld if y > self.height() + fs: self.screen_lines = lineno - self.scroll_pos break if y < self.height() + fs: self.scroll_pos = lineno def keyPressEvent(self, event): c, k = event.text(), event.key() if c == ' ': self.msgno += 1 elif c == '\x08': self.msgno -= 1 elif c == 't': self.find_parent() elif c == 'T': self.find_child() elif c == 'f': self.find_next_from() elif c == 'F': self.find_prev_from() elif c == '+': self.fs = math.ceil(self.fs * 2**.5) or 1 elif c == '-': self.fs = math.floor(self.fs * 2**-.5) elif c == 'r': self.showMinimized() # iconify fname = self.reply_name() with open(fname, 'w') as f: f.writelines(self.generate_reply(self.current())) os.system('emacsclient {} &'.format(fname)) # These reopen the window, but don’t regain its previous # size and position, nor focus. #self.showNormal() #self.raise_() return elif k == QtCore.Qt.Key_PageDown: self.scroll_pos += self.screen_lines elif k == QtCore.Qt.Key_PageUp: self.scroll_pos = max(0, self.scroll_pos - self.screen_lines) else: return self.show_message() def reply_name(self): i = 0 while True: fname = 'reply.{}.rfc822'.format(i) if not os.path.exists(fname): return fname i += 1 def generate_reply(self, msg): yield 'X-Emacs: -*- mail -*-\n' me = 'Kragen Javier Sitaker ' yield 'From: {}\n'.format(me) yield 'To: {}\n'.format(msg.get('reply-to', msg['from'])) msgid = msg.get('message-id', 'nomsgid') yield 'In-Reply-To: {}\n'.format(msgid) if 'references' in msg: refs = '\n\t{}'.format(msg['references']) else: refs = '' yield 'References: {}{}\n'.format(msgid, refs) yield 'MIME-Version: 1.0\n' yield 'Content-Type: text/plain; charset="utf-8"\n' now = datetime.datetime.utcnow() date = now.strftime('%a, %d %b %Y %H:%M:%S +0000') yield 'Date: {}\n'.format(date) key = '{} {} {}'.format(date, me, msgid) newid = hashlib.sha256(key.encode('utf-8')).hexdigest()[:16] yield 'Message-ID: \n'.format(newid) subject = msg.get('subject', 'yer mail') yield 'Subject: {}\n'.format(subject if subject.startswith('Re: ') else 'Re: {}'.format(subject)) yield '\n' yield '{} wrote:\n'.format(msg.get('from', 'Someone')) for line in msg.lines: yield '> ' yield line def current(self): return self.db.get_message(self.msgno) def find_parent(self): msg = self.current() if 'in-reply-to' not in msg: return msgid = msg['in-reply-to'] for msgno in self.db.find({'message-id': msgid}): self.msgno = msgno return # If we got here I guess we failed but what do we do to report it? def find_child(self): for msgno in self.db.find({'in-reply-to': self.current().get('message-id')}): self.msgno = msgno return def find_next_from(self): # XXX This is inefficient but may do for now addr = self.current().get('from', 'no from') for msgno in self.db.find({'from': addr}): if msgno > self.msgno: self.msgno = msgno return def find_prev_from(self): # XXX This is inefficient but may do for now addr = self.current().get('from', 'no from') last_msgno = None for msgno in self.db.find({'from': addr}): if msgno == self.msgno: if last_msgno is not None: self.msgno = last_msgno return last_msgno = msgno if __name__ == '__main__': sys.exit(main())