This commit is contained in:
Geoffrey Frogeye 2018-08-14 17:23:57 +02:00
parent 47cdc830a0
commit f910bd6add
3 changed files with 248 additions and 62 deletions

View File

@ -15,7 +15,7 @@
hide_duplicate_count = false hide_duplicate_count = false
history_length = 20 history_length = 20
horizontal_padding = 8 horizontal_padding = 8
icon_path = /usr/share/icons/gnome/256x256/status/:/usr/share/icons/gnome/256x256/devices/ icon_path = /usr/share/icons/gnome/256x256/actions/:/usr/share/icons/gnome/256x256/status/:/usr/share/icons/gnome/256x256/devices/
icon_position = left icon_position = left
idle_threshold = 120 idle_threshold = 120
ignore_newline = no ignore_newline = no

View File

@ -2,10 +2,35 @@
""" """
Meh mail client Meh mail client
A dumb Python scripts that leverages notmuch, mbsync, and msmtp
to become a fully-functional extremly-opinonated mail client.
""" """
# TODO Features # TODO Features
# TODO Lockfiles for write operations on mail files (mbsync, tags→maildir operations)
# TODO OPTI Lockfile per account and process everything in parallel (if implemented, this
# should be optional since while it may speed up the mail fetching process, its multi-threading
# nature would cause a lot of cache flushes and be not very efficient on battery)
# TODO Handle true character width
# TODO IMAP IDLE watches?
# TODO (only then) Refactor # TODO (only then) Refactor
# TODO OOP-based
# TODO Merge file with melConf
# DEBUG Small perf profiler
import time
perf_dict = dict()
perf_last = time.perf_counter()
def perfstep(name):
t = time.perf_counter()
global perf_last
global perf_dict
diff = t - perf_last
if name not in perf_dict:
perf_dict[name] = 0
perf_dict[name] += diff
perf_last = time.perf_counter()
import notmuch import notmuch
import logging import logging
@ -14,7 +39,6 @@ import colorama
import datetime import datetime
import os import os
import progressbar import progressbar
import time
import argparse import argparse
import configparser import configparser
import base64 import base64
@ -23,9 +47,27 @@ import argparse
import xdg.BaseDirectory import xdg.BaseDirectory
import sys import sys
import subprocess import subprocess
import html
import re
perfstep("import")
ACCOUNTS = dict() ACCOUNTS = dict()
ALIASES = set() ALIASES = set()
db = None
config = None
def open_database(write=False):
global db
mode = notmuch.Database.MODE.READ_WRITE if write else notmuch.Database.MODE.READ_ONLY
dbPath = os.path.realpath(os.path.expanduser(config["GENERAL"]["storage"]))
db = notmuch.Database(mode=mode, path=dbPath)
def close_database():
global db
db.close()
db = None
def generate_aliases(): def generate_aliases():
for name in config.sections(): for name in config.sections():
@ -73,26 +115,25 @@ def format_date(date):
else: else:
return date.strftime('%d/%m/%y') return date.strftime('%d/%m/%y')
def threadIdToB64(tid): WIDTH_FIXED = 31
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 WIDTH_RATIO_DEST_SUBJECT = 0.3
ISATTY = sys.stdout.isatty()
destWidth = None destWidth = None
subjectWidth = None subjectWidth = None
def compute_line_format(): def compute_line_format():
columns, rows = shutil.get_terminal_size((80, 20)) if ISATTY:
remain = columns - WIDTH_FIXED - 1 columns, rows = shutil.get_terminal_size((80, 20))
global destWidth, subjectWidth remain = columns - WIDTH_FIXED - 1
destWidth = int(remain * WIDTH_RATIO_DEST_SUBJECT) global destWidth, subjectWidth
subjectWidth = remain - destWidth destWidth = int(remain * WIDTH_RATIO_DEST_SUBJECT)
subjectWidth = remain - destWidth
else:
destWidth = None
subjectWidth = None
def clip_text(size, text): def clip_text(size, text):
if size is None:
return text
l = len(text) l = len(text)
if l == size: if l == size:
return text return text
@ -106,22 +147,28 @@ def print_msg(msg):
if not destWidth: if not destWidth:
compute_line_format() compute_line_format()
sep = " " if ISATTY else "\t"
line = "" line = ""
tags = set(msg.get_tags()) tags = set(msg.get_tags())
mailbox, folder, state = get_location(msg) mailbox, folder, state = get_location(msg)
line += get_mailbox_color(mailbox) if ISATTY:
line += get_mailbox_color(mailbox)
# ID # UID
line += threadIdToB64(msg.get_thread_id()) uid = None
# line += str(int(msg.get_thread_id(), 16)) for tag in tags:
if tag.startswith('tuid'):
uid = tag[4:]
assert isUID(uid), uid
line += uid
# Date # Date
line += " " line += sep
date = datetime.datetime.fromtimestamp(msg.get_date()) date = datetime.datetime.fromtimestamp(msg.get_date())
line += format_date(date) line += format_date(date)
# Icons # Icons
line += " " line += sep
def tags2col1(tag1, tag2, both, first, second, none): def tags2col1(tag1, tag2, both, first, second, none):
nonlocal line nonlocal line
if tag1 in tags: if tag1 in tags:
@ -144,20 +191,20 @@ def print_msg(msg):
dest = msg.get_header("to") dest = msg.get_header("to")
else: else:
dest = msg.get_header("from") dest = msg.get_header("from")
line += " " line += sep
line += clip_text(destWidth, dest) line += clip_text(destWidth, dest)
# Subject # Subject
line += " " line += sep
subject = msg.get_header("subject") subject = msg.get_header("subject")
line += clip_text(subjectWidth, subject) line += clip_text(subjectWidth, subject)
line += colorama.Style.RESET_ALL if ISATTY:
line += colorama.Style.RESET_ALL
print(line) print(line)
def retag_msg(msg): def retag_msg(msg):
msg.freeze()
mailbox, folder, state = get_location(msg) mailbox, folder, state = get_location(msg)
# Search-friendly folder name # Search-friendly folder name
@ -181,11 +228,18 @@ def retag_msg(msg):
tag_if('deleted', slugFolder[0] == 'TRASH') tag_if('deleted', slugFolder[0] == 'TRASH')
tag_if('draft', slugFolder[0] == 'DRAFTS') tag_if('draft', slugFolder[0] == 'DRAFTS')
tag_if('sent', expeditor in ALIASES) tag_if('sent', expeditor in ALIASES)
# TODO remove unprocessed tag_if('unprocessed', False)
# UID
uid = msg.get_header("X-TUID")
assert isUID(uid)
uidtag = 'tuid{}'.format(uid)
# Remove eventual others UID
for tag in tags:
if tag.startswith('tuid') and tag != uidtag:
msg.remove_tag(tag)
msg.add_tag(uidtag)
# Save
msg.thaw()
msg.tags_to_maildir_flags()
def extract_email(field): def extract_email(field):
@ -196,23 +250,37 @@ def extract_email(field):
except ValueError: except ValueError:
return field return field
def applyMsgs(queryStr, action, *args, showProgress=False, **kwargs): msg = None
def applyMsgs(queryStr, action, *args, showProgress=False, write=False, closeDb=True, **kwargs):
if db is None:
open_database(write=write)
log.info("Querying {}".format(queryStr)) log.info("Querying {}".format(queryStr))
query = notmuch.Query(db, queryStr) query = notmuch.Query(db, queryStr)
query.set_sort(notmuch.Query.SORT.OLDEST_FIRST) query.set_sort(notmuch.Query.SORT.OLDEST_FIRST)
elements = query.search_messages() elements = query.search_messages()
nbMsgs = query.count_messages()
if showProgress: iterator = progressbar.progressbar(elements, max_value=nbMsgs) if showProgress else elements
nbMsgs = query.count_messages()
iterator = progressbar.progressbar(elements, max_value=nbMsgs)
else:
iterator = elements
log.info("Executing {}".format(action)) log.info("Executing {}".format(action))
global msg
for msg in iterator: for msg in iterator:
if write:
msg.freeze()
action(msg, *args, **kwargs) action(msg, *args, **kwargs)
if write:
msg.thaw()
msg.tags_to_maildir_flags()
if closeDb:
close_database()
return nbMsgs
# applyMsgs('*', print_msg) # applyMsgs('*', print_msg)
# applyMsgs('tag:inbox', print_msg) # applyMsgs('tag:inbox', print_msg)
# applyMsgs('tag:spam', print_msg) # applyMsgs('tag:spam', print_msg)
@ -223,6 +291,92 @@ def applyMsgs(queryStr, action, *args, showProgress=False, **kwargs):
# applyMsgs('tag:unprocessed', retag_msg, useProgressbar=True) # applyMsgs('tag:unprocessed', retag_msg, useProgressbar=True)
# applyMsgs('*', retag_msg, useProgressbar=True) # applyMsgs('*', retag_msg, useProgressbar=True)
# def update_polybar_status():
def update_polybar_status(*args, **kwargs):
log.info("Updating polybar status")
accountsList = sorted(ACCOUNTS.keys())
print(accountsList)
open_database()
statusArr = []
for account in accountsList:
queryStr = 'folder:/{}/ and tag:unread'.format(account)
query = notmuch.Query(db, queryStr)
nbMsgs = query.count_messages()
if nbMsgs < 1:
continue
color = config[account]['color']
statusAccStr = '%{F' + color + '}' + str(nbMsgs) + '%{F-}'
statusArr.append(statusAccStr)
close_database()
statusStr = ('_' + ' '.join(statusArr)) if len(statusArr) else '\n'
statusPath = os.path.expanduser("~/.cache/mutt/status") # TODO Better
with open(statusPath, 'w') as f:
f.write(statusStr)
# statusPath = os.path.expanduser("~/.cache/mel/polybarstatus") # TODO Better
def notify_msg(msg):
log.info("Sending notification for {}".format(msg))
subject = msg.get_header("subject")
expd = msg.get_header("from")
account, _, _ = get_location(msg)
summary = '{} (<i>{}</i>)'.format(html.escape(expd), account)
body = html.escape(subject)
cmd = ["notify-send", "-u", "low", "-i", "mail-message-new", summary, body]
print(' '.join(cmd))
subprocess.run(cmd)
def notify_all(*args, **kwargs):
open_database()
nbMsgs = applyMsgs('tag:unread and tag:unprocessed', notify_msg)
if nbMsgs > 0:
log.info("Playing notification sound ({} new message(s))".format(nbMsgs))
cmd = ["play", "-n", "synth", "sine", "E4", "sine", "A5", "remix", "1-2", "fade", "0.5", "1.2", "0.5", "2"]
subprocess.run(cmd)
close_database()
def isUID(uid):
return isinstance(uid, str) and len(uid) == 12 and re.match('^[a-zA-Z0-9+/]{12}$', uid)
# From https://stackoverflow.com/a/312464
def chunks(l, n):
"""Yield successive n-sized chunks from l."""
for i in range(0, len(l), n):
yield l[i:i + n]
def apply_msgs_input(argmessages, action, write=False):
if not len(argmessages):
fromStdin = not sys.stdin.isatty()
else:
fromStdin = len(argmessages) == 1 and argmessages == '-'
messages = list()
if fromStdin:
for line in sys.stdin:
uid = line[:12]
if not isUID(uid):
log.error("Not an UID: {}".format(uid))
continue
messages.append(uid)
else:
for uids in argmessages:
if len(uids) > 12:
log.warn("Might have forgotten some spaces between the UIDs. Don't worry, I'll split them for you")
for uid in chunks(uids, 12):
if not isUID(uid):
log.error("Not an UID: {}".format(uid))
continue
messages.append(uid)
for message in messages:
queryStr = 'tag:tuid{}'.format(message)
nbMsgs = applyMsgs(queryStr, action, write=write)
if nbMsgs < 1:
log.error("Couldn't execute function for message {}".format(message))
perfstep("definitions")
if __name__ == "__main__": if __name__ == "__main__":
# Main arguments # Main arguments
parser = argparse.ArgumentParser(description="Meh mail client") parser = argparse.ArgumentParser(description="Meh mail client")
@ -232,18 +386,13 @@ if __name__ == "__main__":
defaultConfigFile = os.path.join(xdg.BaseDirectory.xdg_config_home, 'mel', 'accounts.conf') defaultConfigFile = os.path.join(xdg.BaseDirectory.xdg_config_home, 'mel', 'accounts.conf')
parser.add_argument('-c', '--config', default=defaultConfigFile, help="Accounts config file") parser.add_argument('-c', '--config', default=defaultConfigFile, help="Accounts config file")
parser.set_defaults(dbmode=notmuch.Database.MODE.READ_ONLY) subparsers = parser.add_subparsers(help="Action to execute")
parser.set_defaults(showProgress=False)
parser.set_defaults(useThreads=False)
parser.set_defaults(actionBefore=None)
parser.set_defaults(actionAfter=None)
parser.set_defaults(action=None)
subparsers = parser.add_subparsers(help="Action to execute", required=True)
## List messages ## List messages
def func_default(args):
applyMsgs('tag:inbox', print_msg)
parser.set_defaults(func=func_default)
# inbox (default) # inbox (default)
def func_inbox(args): def func_inbox(args):
@ -258,10 +407,30 @@ if __name__ == "__main__":
# list folder [--recurse] # list folder [--recurse]
## List actions ## List actions
# flag msg...
def func_flag(args):
def flag_msg(msg):
msg.add_tag('flagged')
apply_msgs_input(args.message, action=flag_msg, write=True)
parserFlag = subparsers.add_parser("flag", help="Mark messages as flagged")
parserFlag.add_argument('message', nargs='*', help="Messages")
parserFlag.set_defaults(func=func_flag)
# unflag msg...
def func_unflag(args):
def unflag_msg(msg):
msg.remove_tag('flagged')
apply_msgs_input(args.message, action=unflag_msg, write=True)
parserUnflag = subparsers.add_parser("unflag", help="Mark messages as not-flagged")
parserUnflag.add_argument('message', nargs='*', help="Messages")
parserUnflag.set_defaults(func=func_unflag)
# delete msg... # delete msg...
# spam msg... # spam msg...
# flag msg...
# ↑un* equivalents
# move dest msg... # move dest msg...
## Read message ## Read message
# read msg [--html] [--plain] [--browser] # read msg [--html] [--plain] [--browser]
@ -270,6 +439,7 @@ if __name__ == "__main__":
# new account # new account
# reply msg [--all] # reply msg [--all]
## Folder management ## Folder management
# tree [folder]
# mkdir folder # mkdir folder
# rmdir folder (prevent if folder isn't empty (mail/subfolder)) # rmdir folder (prevent if folder isn't empty (mail/subfolder))
# (yeah that should do) # (yeah that should do)
@ -293,25 +463,40 @@ if __name__ == "__main__":
log.debug(" ".join(cmd)) log.debug(" ".join(cmd))
subprocess.run(cmd) subprocess.run(cmd)
# Tag new mails
applyMsgs('tag:unprocessed', retag_msg, showProgress=True)
# Notify # Notify
log.info("Notifying new mails") notify_all()
# TODO Maybe before retag, notify unprocessed && unread update_polybar_status()
# Tag new mails
applyMsgs('tag:unprocessed', retag_msg, showProgress=True, write=True)
parserFetch = subparsers.add_parser("fetch", help="Fetch mail, tag them, and run notifications") parserFetch = subparsers.add_parser("fetch", help="Fetch mail, tag them, and run notifications")
parserFetch.set_defaults(dbmode=notmuch.Database.MODE.READ_WRITE)
parserFetch.set_defaults(func=func_fetch) parserFetch.set_defaults(func=func_fetch)
## Debug ## Debug
# process (all or unprocessed) # debug (various)
parserRetag = subparsers.add_parser("debug", help="Who know what this holds...")
parserRetag.set_defaults(verbosity='DEBUG')
parserRetag.set_defaults(func=notify_all)
# retag (all or unprocessed)
def func_retag(args):
applyMsgs('*', retag_msg, showProgress=True, write=True)
parserRetag = subparsers.add_parser("retag", help="Retag all mails (when you changed configuration)")
parserRetag.set_defaults(func=func_retag)
# all
def func_all(args):
applyMsgs('*', print_msg)
parserAll = subparsers.add_parser("all", help="Show ALL messages")
parserAll.set_defaults(func=func_all)
# Init
args = parser.parse_args() args = parser.parse_args()
print(args) perfstep("parse_args")
# Installing logs
colorama.init() colorama.init()
coloredlogs.install(level=args.verbosity, fmt='%(levelname)s %(message)s') coloredlogs.install(level=args.verbosity, fmt='%(levelname)s %(message)s')
log = logging.getLogger() log = logging.getLogger()
@ -325,13 +510,14 @@ if __name__ == "__main__":
config.read(args.config) config.read(args.config)
generate_aliases() generate_aliases()
perfstep("config")
if args.dbmode is not None:
log.info("Loading database")
dbPath = os.path.realpath(os.path.expanduser(config["GENERAL"]["storage"]))
db = notmuch.Database(mode=args.dbmode, path=dbPath)
if args.func: if args.func:
log.info("Executing function {}".format(args.func)) log.info("Executing function {}".format(args.func))
args.func(args) args.func(args)
perfstep("exec")
# DEBUG
for kv in sorted(perf_dict.items(), key=lambda p: p[1]):
log.debug("{1:.6f} {0}".format(*kv))

View File

@ -203,7 +203,7 @@ primary_email={main[from]}
other_email={other_email} other_email={other_email}
[new] [new]
tags=unprocessed; tags=unprocessed;unread;
ignore= ignore=
[search] [search]