This commit is contained in:
Geoffrey Frogeye 2018-08-13 17:59:40 +02:00
parent 86bc146125
commit 18a7278422
2 changed files with 188 additions and 46 deletions

View file

@ -4,6 +4,9 @@
Meh mail client Meh mail client
""" """
# TODO Features
# TODO (only then) Refactor
import notmuch import notmuch
import logging import logging
import coloredlogs import coloredlogs
@ -12,14 +15,40 @@ import datetime
import os import os
import progressbar import progressbar
import time import time
import argparse
import configparser
import base64
import shutil
colorama.init() colorama.init()
coloredlogs.install(level='DEBUG', fmt='%(levelname)s %(message)s') coloredlogs.install(level='DEBUG', fmt='%(levelname)s %(message)s')
log = logging.getLogger() 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") log.debug("Loading database")
db = notmuch.Database(mode=notmuch.Database.MODE.READ_WRITE) 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 # TODO Open read-only when needed
log.debug("Database loaded") log.debug("Database loaded")
@ -30,7 +59,10 @@ def get_location(msg):
base = db.get_path() base = db.get_path()
assert path.startswith(base) assert path.startswith(base)
path = path[len(base):] path = path[len(base):]
_, mailbox, folder, state = path.split('/') pathSplit = path.split('/')
mailbox = pathSplit[1]
state = pathSplit[-1]
folder = tuple(pathSplit[2:-1])
assert state in {'cur', 'tmp', 'new'} assert state in {'cur', 'tmp', 'new'}
return (mailbox, folder, state) return (mailbox, folder, state)
@ -38,10 +70,7 @@ MAILBOX_COLORS = dict()
def get_mailbox_color(mailbox): def get_mailbox_color(mailbox):
if mailbox not in MAILBOX_COLORS: if mailbox not in MAILBOX_COLORS:
colorfile = os.path.join(db.get_path(), mailbox, 'color') colorStr = config[mailbox]["color"]
assert os.path.isfile(colorfile)
with open(colorfile, 'r') as f:
colorStr = f.read()
colorStr = colorStr[1:] if colorStr[0] == '#' else colorStr colorStr = colorStr[1:] if colorStr[0] == '#' else colorStr
R = int(colorStr[0:2], 16) R = int(colorStr[0:2], 16)
G = int(colorStr[2:4], 16) G = int(colorStr[2:4], 16)
@ -57,14 +86,50 @@ def format_date(date):
else: else:
return date.strftime('%d/%m/%y') 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): def print_msg(msg):
if not destWidth:
compute_line_format()
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) line += get_mailbox_color(mailbox)
# ID
line += threadIdToB64(msg.get_thread_id())
# line += str(int(msg.get_thread_id(), 16))
# Date # Date
line += " "
date = datetime.datetime.fromtimestamp(msg.get_date()) date = datetime.datetime.fromtimestamp(msg.get_date())
line += format_date(date) line += format_date(date)
@ -83,16 +148,23 @@ def print_msg(msg):
else: else:
line += none line += none
tags2col1('spam', 'draft', '??', '💥', '📝', ' ') tags2col1('spam', 'draft', '?', 'S', 'D', ' ')
tags2col1('attachment', 'encrypted', '🔐', '📎', '🔑', ' ') tags2col1('attachment', 'encrypted', 'E', 'A', 'E', ' ')
tags2col1('unread', 'flagged', '🏁', '🏳 ', '🏴', ' ') tags2col1('unread', 'flagged', '!', 'U', 'F', ' ')
tags2col1('sent', 'replied', '?', '↑', '↪', ' ') tags2col1('sent', 'replied', '?', '↑', '↪', ' ')
# TODO To: / From: if 'sent' in tags:
dest = msg.get_header("to")
else:
dest = msg.get_header("from")
line += " "
line += clip_text(destWidth, dest)
# Subject # Subject
line += " " line += " "
line += msg.get_header("subject") subject = msg.get_header("subject")
line += clip_text(subjectWidth, subject)
line += colorama.Style.RESET_ALL line += colorama.Style.RESET_ALL
print(line) print(line)
@ -102,26 +174,33 @@ def retag_msg(msg):
mailbox, folder, state = get_location(msg) mailbox, folder, state = get_location(msg)
# Search-friendly folder name # Search-friendly folder name
if folder.startswith('INBOX.'): slugFolderList = list()
folder = folder[6:] for f, fold in [(f, folder[f]) for f in range(len(folder))]:
folder = folder.upper() if f == 0 and len(folder) > 1 and fold == "INBOX":
continue
slugFolderList.append(fold.upper())
slugFolder = tuple(slugFolderList)
msg.remove_all_tags() tags = set(msg.get_tags())
if folder.startswith('JUNK') or folder.startswith('SPAM'): def tag_if(tag, condition):
msg.add_tag('spam') if condition and tag not in tags:
if folder.startswith('DRAFT'): msg.add_tag(tag)
msg.add_tag('draft') elif not condition and tag in tags:
if folder.startswith('INBOX'): msg.remove_tag(tag)
msg.add_tag('inbox') expeditor = extract_email(msg.get_header('from'))
# TODO 'sent' tag 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 # Save
msg.thaw() msg.thaw()
msg.tags_to_maildir_flags() msg.tags_to_maildir_flags()
def applyMsgs(queryStr, function, useProgressbar=False): def applyMsgs(queryStr, function, *args, useProgressbar=False, **kwargs):
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)
msgs = query.search_messages() msgs = query.search_messages()
@ -131,11 +210,23 @@ def applyMsgs(queryStr, function, useProgressbar=False):
else: else:
iterator = msgs iterator = msgs
for msg in iterator: for msg in iterator:
function(msg) 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:inbox', print_msg)
# applyMsgs('tag:spam', print_msg) # applyMsgs('tag:spam', print_msg)
# applyMsgs('tag:unread', print_msg) # applyMsgs('tag:unread', print_msg)
# applyMsgs('tag:unprocessed', print_msg)
# applyMsgs('from:geoffrey@frogeye.fr', print_msg) # applyMsgs('from:geoffrey@frogeye.fr', print_msg)
# applyMsgs('tag:unprocessed', retag_msg, useProgressbar=True)
# applyMsgs('*', retag_msg, useProgressbar=True) # applyMsgs('*', retag_msg, useProgressbar=True)

View file

@ -9,16 +9,18 @@ import os
import sys import sys
# TODO Find config file from XDG # TODO Find config file from XDG
# TODO Alias adresses
# TODO Signature file # TODO Signature file
# TODO Write ~/.mail/[mailbox]/color file if required by sth? # TODO Write ~/.mail/[mailbox]/color file if required by sth?
# Certificate file # TODO Fix IMAPS with mbsync
configPath = os.path.join(os.path.expanduser('~'), '.config', 'mel.conf') configPath = os.path.join(os.path.expanduser('~'), '.config', 'mel', 'accounts.conf')
config = configparser.ConfigParser() config = configparser.ConfigParser()
config.read(configPath) config.read(configPath)
storageFull = os.path.realpath(os.path.expanduser(config["GENERAL"]["storage"]))
config["GENERAL"]["storage"] = storageFull
SERVER_DEFAULTS = { SERVER_DEFAULTS = {
"imap": {"port": 143, "starttls": True}, "imap": {"port": 143, "starttls": True},
"smtp": {"port": 587, "starttls": True}, "smtp": {"port": 587, "starttls": True},
@ -27,6 +29,7 @@ SERVER_ITEMS = {"host", "port", "user", "pass", "starttls"}
# Reading sections # Reading sections
accounts = dict() accounts = dict()
mails = set()
for name in config.sections(): for name in config.sections():
if not name.islower(): if not name.islower():
@ -56,13 +59,23 @@ for name in config.sections():
continue continue
data[key] = section[key] data[key] = section[key]
data["name"] = name mails.add(section["from"])
data["storage"] = os.path.join(config['GLOBAL']['storage'], name) if "alternatives" in section:
for alt in section["alternatives"].split(";"):
mails.add(alt)
data["account"] = name
data["storage"] = os.path.join(config['GENERAL']['storage'], name)
data["storageInbox"] = os.path.join(data["storage"], "INBOX") data["storageInbox"] = os.path.join(data["storage"], "INBOX")
storageFull = os.path.expanduser(data["storage"])
os.makedirs(storageFull, exist_ok=True)
accounts[name] = data accounts[name] = data
general = dict()
section = config["GENERAL"]
for key in section.keys():
general[key] = section[key]
general["main"] = accounts[general["main"]]
# OfflineIMAP # OfflineIMAP
OFFLINEIMAP_BEGIN = """[general] OFFLINEIMAP_BEGIN = """[general]
@ -82,19 +95,19 @@ footer = "\\n"
""" """
OFFLINEIMAP_ACCOUNT = """[Account {name}] OFFLINEIMAP_ACCOUNT = """[Account {account}]
localrepository = {name}-local localrepository = {account}-local
remoterepository = {name}-remote remoterepository = {account}-remote
autorefresh = 0.5 autorefresh = 0.5
quick = 10 quick = 10
utf8foldernames = yes utf8foldernames = yes
postsynchook = ~/.mutt/postsync postsynchook = ~/.mutt/postsync
[Repository {name}-local] [Repository {account}-local]
type = Maildir type = Maildir
localfolders = {storage} localfolders = {storage}
[Repository {name}-remote] [Repository {account}-remote]
type = IMAP type = IMAP
{secconf} {secconf}
keepalive = 60 keepalive = 60
@ -116,23 +129,24 @@ for name, account in accounts.items():
# TODO Write # TODO Write
# mbsync # mbsync
MBSYNC_ACCOUNT = """IMAPAccount {name} MBSYNC_ACCOUNT = """IMAPAccount {account}
Host {imaphost} Host {imaphost}
Port {imapport}
User {imapuser} User {imapuser}
Pass "{imappass}" Pass "{imappassEscaped}"
{secconf} {secconf}
IMAPStore {name}-remote IMAPStore {account}-remote
Account {name} Account {account}
MaildirStore {name}-local MaildirStore {account}-local
Subfolders Verbatim Subfolders Verbatim
Path {storage}/ Path {storage}/
Inbox {storageInbox}/ Inbox {storageInbox}/
Channel {name} Channel {account}
Master :{name}-remote: Master :{account}-remote:
Slave :{name}-local: Slave :{account}-local:
Patterns * Patterns *
Create Both Create Both
SyncState * SyncState *
@ -145,7 +159,10 @@ for name, account in accounts.items():
secconf = "SSLType STARTTLS" secconf = "SSLType STARTTLS"
else: else:
secconf = "SSLType IMAPS" secconf = "SSLType IMAPS"
mbsyncStr += MBSYNC_ACCOUNT.format(**account, secconf=secconf) if "certificate" in account:
secconf += "\nCertificateFile {certificate}".format(**account)
imappassEscaped = account["imappass"].replace("\\", "\\\\")
mbsyncStr += MBSYNC_ACCOUNT.format(**account, secconf=secconf, imappassEscaped=imappassEscaped)
msbsyncFilepath = os.path.join(os.path.expanduser('~'), '.mbsyncrc') msbsyncFilepath = os.path.join(os.path.expanduser('~'), '.mbsyncrc')
with open(msbsyncFilepath, 'w') as f: with open(msbsyncFilepath, 'w') as f:
f.write(mbsyncStr) f.write(mbsyncStr)
@ -158,7 +175,7 @@ tls_trust_file /etc/ssl/certs/ca-certificates.crt
""" """
MSMTP_ACCOUNT = """account {name} MSMTP_ACCOUNT = """account {account}
from {from} from {from}
user {smtpuser} user {smtpuser}
password {smtppass} password {smtppass}
@ -174,3 +191,37 @@ for name, account in accounts.items():
msbsyncFilepath = os.path.join(os.path.expanduser('~'), '.msmtprc') msbsyncFilepath = os.path.join(os.path.expanduser('~'), '.msmtprc')
with open(msbsyncFilepath, 'w') as f: with open(msbsyncFilepath, 'w') as f:
f.write(msmtpStr) f.write(msmtpStr)
# notmuch
NOTMUCH_BEGIN = """[database]
path={storage}
[user]
name={main[name]}
primary_email={main[from]}
other_email={other_email}
[new]
tags=unprocessed;
ignore=
[search]
exclude_tags=deleted;spam;
[maildir]
synchronize_flags=true
[crypto]
gpg_path=gpg
"""
other_email = mails.copy()
other_email.remove(general["main"]["from"])
other_email = ";".join(other_email)
notmuchStr = NOTMUCH_BEGIN.format(**general, other_email=other_email)
msbsyncFilepath = os.path.join(os.path.expanduser('~'), '.notmuchrc')
with open(msbsyncFilepath, 'w') as f:
f.write(notmuchStr)