diff --git a/config/dunst/dunstrc b/config/dunst/dunstrc index 63e6c2a..17278d9 100644 --- a/config/dunst/dunstrc +++ b/config/dunst/dunstrc @@ -15,7 +15,7 @@ hide_duplicate_count = false history_length = 20 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 idle_threshold = 120 ignore_newline = no diff --git a/scripts/mel b/scripts/mel index 7a4dae5..71869c2 100755 --- a/scripts/mel +++ b/scripts/mel @@ -2,10 +2,35 @@ """ 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 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 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 logging @@ -14,7 +39,6 @@ import colorama import datetime import os import progressbar -import time import argparse import configparser import base64 @@ -23,9 +47,27 @@ import argparse import xdg.BaseDirectory import sys import subprocess +import html +import re + +perfstep("import") ACCOUNTS = dict() 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(): for name in config.sections(): @@ -73,26 +115,25 @@ def format_date(date): 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_FIXED = 31 WIDTH_RATIO_DEST_SUBJECT = 0.3 +ISATTY = sys.stdout.isatty() 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 + if ISATTY: + 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 + else: + destWidth = None + subjectWidth = None def clip_text(size, text): + if size is None: + return text l = len(text) if l == size: return text @@ -106,22 +147,28 @@ def print_msg(msg): if not destWidth: compute_line_format() + sep = " " if ISATTY else "\t" line = "" tags = set(msg.get_tags()) mailbox, folder, state = get_location(msg) - line += get_mailbox_color(mailbox) + if ISATTY: + line += get_mailbox_color(mailbox) - # ID - line += threadIdToB64(msg.get_thread_id()) - # line += str(int(msg.get_thread_id(), 16)) + # UID + uid = None + for tag in tags: + if tag.startswith('tuid'): + uid = tag[4:] + assert isUID(uid), uid + line += uid # Date - line += " " + line += sep date = datetime.datetime.fromtimestamp(msg.get_date()) line += format_date(date) # Icons - line += " " + line += sep def tags2col1(tag1, tag2, both, first, second, none): nonlocal line if tag1 in tags: @@ -144,20 +191,20 @@ def print_msg(msg): dest = msg.get_header("to") else: dest = msg.get_header("from") - line += " " + line += sep line += clip_text(destWidth, dest) # Subject - line += " " + line += sep subject = msg.get_header("subject") line += clip_text(subjectWidth, subject) - line += colorama.Style.RESET_ALL + if ISATTY: + line += colorama.Style.RESET_ALL print(line) def retag_msg(msg): - msg.freeze() mailbox, folder, state = get_location(msg) # Search-friendly folder name @@ -181,11 +228,18 @@ def retag_msg(msg): tag_if('deleted', slugFolder[0] == 'TRASH') tag_if('draft', slugFolder[0] == 'DRAFTS') 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): @@ -196,23 +250,37 @@ def extract_email(field): except ValueError: 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)) query = notmuch.Query(db, queryStr) query.set_sort(notmuch.Query.SORT.OLDEST_FIRST) elements = query.search_messages() + nbMsgs = query.count_messages() - if showProgress: - nbMsgs = query.count_messages() - iterator = progressbar.progressbar(elements, max_value=nbMsgs) - else: - iterator = elements + iterator = progressbar.progressbar(elements, max_value=nbMsgs) if showProgress else elements log.info("Executing {}".format(action)) + global msg for msg in iterator: + if write: + msg.freeze() + action(msg, *args, **kwargs) + if write: + msg.thaw() + msg.tags_to_maildir_flags() + + if closeDb: + close_database() + + return nbMsgs + # applyMsgs('*', print_msg) # applyMsgs('tag:inbox', 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('*', 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 = '{} ({})'.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__": # Main arguments 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') parser.add_argument('-c', '--config', default=defaultConfigFile, help="Accounts config file") - parser.set_defaults(dbmode=notmuch.Database.MODE.READ_ONLY) - 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) - + subparsers = parser.add_subparsers(help="Action to execute") ## List messages + def func_default(args): + applyMsgs('tag:inbox', print_msg) + parser.set_defaults(func=func_default) # inbox (default) def func_inbox(args): @@ -258,10 +407,30 @@ if __name__ == "__main__": # list folder [--recurse] ## 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... # spam msg... - # flag msg... - # ↑un* equivalents # move dest msg... ## Read message # read msg [--html] [--plain] [--browser] @@ -270,6 +439,7 @@ if __name__ == "__main__": # new account # reply msg [--all] ## Folder management + # tree [folder] # mkdir folder # rmdir folder (prevent if folder isn't empty (mail/subfolder)) # (yeah that should do) @@ -293,25 +463,40 @@ if __name__ == "__main__": log.debug(" ".join(cmd)) subprocess.run(cmd) - # Tag new mails - applyMsgs('tag:unprocessed', retag_msg, showProgress=True) - # Notify - log.info("Notifying new mails") - # TODO Maybe before retag, notify unprocessed && unread + notify_all() + 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.set_defaults(dbmode=notmuch.Database.MODE.READ_WRITE) parserFetch.set_defaults(func=func_fetch) ## 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() - print(args) + perfstep("parse_args") - # Installing logs colorama.init() coloredlogs.install(level=args.verbosity, fmt='%(levelname)s %(message)s') log = logging.getLogger() @@ -325,13 +510,14 @@ if __name__ == "__main__": config.read(args.config) generate_aliases() - - 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) + perfstep("config") if args.func: log.info("Executing function {}".format(args.func)) 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)) diff --git a/scripts/melConf b/scripts/melConf index a0c2829..c46e30e 100755 --- a/scripts/melConf +++ b/scripts/melConf @@ -203,7 +203,7 @@ primary_email={main[from]} other_email={other_email} [new] -tags=unprocessed; +tags=unprocessed;unread; ignore= [search]