#!/usr/bin/env python3 """ Meh mail client """ # TODO Features # TODO (only then) Refactor import notmuch import logging import coloredlogs import colorama import datetime import os import progressbar import time import argparse import configparser import base64 import shutil colorama.init() coloredlogs.install(level='DEBUG', fmt='%(levelname)s %(message)s') log = logging.getLogger() log.debug("Loading config") # TODO XDG configPath = os.path.join(os.path.expanduser('~'), '.config', 'mel', 'accounts.conf') config = configparser.ConfigParser() config.read(configPath) # Reading config a bit accounts = dict() mails = set() for name in config.sections(): if not name.islower(): continue section = config[name] mails.add(section["from"]) if "alternatives" in section: for alt in section["alternatives"].split(";"): mails.add(alt) accounts[name] = section log.debug("Loading database") dbPath = os.path.realpath(os.path.expanduser(config["GENERAL"]["storage"])) db = notmuch.Database(mode=notmuch.Database.MODE.READ_WRITE, path=dbPath) # TODO Open read-only when needed log.debug("Database loaded") def get_location(msg): path = msg.get_filename() path = os.path.dirname(path) base = db.get_path() assert path.startswith(base) path = path[len(base):] pathSplit = path.split('/') mailbox = pathSplit[1] state = pathSplit[-1] folder = tuple(pathSplit[2:-1]) assert state in {'cur', 'tmp', 'new'} return (mailbox, folder, state) MAILBOX_COLORS = dict() def get_mailbox_color(mailbox): if mailbox not in MAILBOX_COLORS: colorStr = config[mailbox]["color"] colorStr = colorStr[1:] if colorStr[0] == '#' else colorStr R = int(colorStr[0:2], 16) G = int(colorStr[2:4], 16) B = int(colorStr[4:6], 16) MAILBOX_COLORS[mailbox] = '\x1b[38;2;{};{};{}m'.format(R, G, B) return MAILBOX_COLORS[mailbox] def format_date(date): now = datetime.datetime.now() midnight = datetime.datetime(year=now.year, month=now.month, day=now.day) if date > midnight: return date.strftime('%H:%M:%S') else: return date.strftime('%d/%m/%y') def threadIdToB64(tid): assert len(tid) == 16 tidInt = int(tid, 16) tidBytes = tidInt.to_bytes(8, 'big') tidB64 = base64.b64encode(tidBytes) assert len(tidB64) == 12 return tidB64.decode() WIDTH_FIXED = 30 WIDTH_RATIO_DEST_SUBJECT = 0.3 destWidth = None subjectWidth = None def compute_line_format(): columns, rows = shutil.get_terminal_size((80, 20)) remain = columns - WIDTH_FIXED - 1 global destWidth, subjectWidth destWidth = int(remain * WIDTH_RATIO_DEST_SUBJECT) subjectWidth = remain - destWidth def clip_text(size, text): l = len(text) if l == size: return text elif l > size: return text[:size-1] + '…' elif l < size: return text + " " * (size - l) def print_msg(msg): if not destWidth: compute_line_format() line = "" tags = set(msg.get_tags()) mailbox, folder, state = get_location(msg) line += get_mailbox_color(mailbox) # ID line += threadIdToB64(msg.get_thread_id()) # line += str(int(msg.get_thread_id(), 16)) # Date line += " " date = datetime.datetime.fromtimestamp(msg.get_date()) line += format_date(date) # Icons line += " " def tags2col1(tag1, tag2, both, first, second, none): nonlocal line if tag1 in tags: if tag2 in tags: line += both else: line += first else: if tag2 in tags: line += second else: line += none tags2col1('spam', 'draft', '?', 'S', 'D', ' ') tags2col1('attachment', 'encrypted', 'E', 'A', 'E', ' ') tags2col1('unread', 'flagged', '!', 'U', 'F', ' ') tags2col1('sent', 'replied', '?', '↑', '↪', ' ') if 'sent' in tags: dest = msg.get_header("to") else: dest = msg.get_header("from") line += " " line += clip_text(destWidth, dest) # Subject line += " " subject = msg.get_header("subject") line += clip_text(subjectWidth, subject) line += colorama.Style.RESET_ALL print(line) def retag_msg(msg): msg.freeze() mailbox, folder, state = get_location(msg) # Search-friendly folder name slugFolderList = list() for f, fold in [(f, folder[f]) for f in range(len(folder))]: if f == 0 and len(folder) > 1 and fold == "INBOX": continue slugFolderList.append(fold.upper()) slugFolder = tuple(slugFolderList) tags = set(msg.get_tags()) def tag_if(tag, condition): if condition and tag not in tags: msg.add_tag(tag) elif not condition and tag in tags: msg.remove_tag(tag) expeditor = extract_email(msg.get_header('from')) tag_if('inbox', slugFolder[0] == 'INBOX') tag_if('spam', slugFolder[0] == 'JUNK' or slugFolder[0] == 'SPAM') tag_if('deleted', slugFolder[0] == 'TRASH') tag_if('draft', slugFolder[0] == 'DRAFTS') tag_if('sent', expeditor in mails) # Save msg.thaw() msg.tags_to_maildir_flags() def applyMsgs(queryStr, function, *args, useProgressbar=False, **kwargs): query = notmuch.Query(db, queryStr) query.set_sort(notmuch.Query.SORT.OLDEST_FIRST) msgs = query.search_messages() if useProgressbar: nbMsgs = query.count_messages() iterator = progressbar.progressbar(msgs, max_value=nbMsgs) else: iterator = msgs for msg in iterator: function(msg, *args, **kwargs) def extract_email(field): try: sta = field.index('<') sto = field.index('>') return field[sta+1:sto] except ValueError: return field # applyMsgs('*', print_msg) applyMsgs('tag:inbox', print_msg) # applyMsgs('tag:spam', print_msg) # applyMsgs('tag:unread', print_msg) # applyMsgs('tag:unprocessed', print_msg) # applyMsgs('from:geoffrey@frogeye.fr', print_msg) # applyMsgs('tag:unprocessed', retag_msg, useProgressbar=True) # applyMsgs('*', retag_msg, useProgressbar=True)