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
"""
# TODO Features
# TODO (only then) Refactor
import notmuch
import logging
import coloredlogs
@ -12,14 +15,40 @@ 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")
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
log.debug("Database loaded")
@ -30,7 +59,10 @@ def get_location(msg):
base = db.get_path()
assert path.startswith(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'}
return (mailbox, folder, state)
@ -38,10 +70,7 @@ MAILBOX_COLORS = dict()
def get_mailbox_color(mailbox):
if mailbox not in MAILBOX_COLORS:
colorfile = os.path.join(db.get_path(), mailbox, 'color')
assert os.path.isfile(colorfile)
with open(colorfile, 'r') as f:
colorStr = f.read()
colorStr = config[mailbox]["color"]
colorStr = colorStr[1:] if colorStr[0] == '#' else colorStr
R = int(colorStr[0:2], 16)
G = int(colorStr[2:4], 16)
@ -57,14 +86,50 @@ 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_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)
@ -83,16 +148,23 @@ def print_msg(msg):
else:
line += none
tags2col1('spam', 'draft', '??', '💥', '📝', ' ')
tags2col1('attachment', 'encrypted', '🔐', '📎', '🔑', ' ')
tags2col1('unread', 'flagged', '🏁', '🏳 ', '🏴', ' ')
tags2col1('spam', 'draft', '?', 'S', 'D', ' ')
tags2col1('attachment', 'encrypted', 'E', 'A', 'E', ' ')
tags2col1('unread', 'flagged', '!', 'U', 'F', ' ')
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
line += " "
line += msg.get_header("subject")
subject = msg.get_header("subject")
line += clip_text(subjectWidth, subject)
line += colorama.Style.RESET_ALL
print(line)
@ -102,26 +174,33 @@ def retag_msg(msg):
mailbox, folder, state = get_location(msg)
# Search-friendly folder name
if folder.startswith('INBOX.'):
folder = folder[6:]
folder = folder.upper()
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)
msg.remove_all_tags()
if folder.startswith('JUNK') or folder.startswith('SPAM'):
msg.add_tag('spam')
if folder.startswith('DRAFT'):
msg.add_tag('draft')
if folder.startswith('INBOX'):
msg.add_tag('inbox')
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'))
# 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
msg.thaw()
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.set_sort(notmuch.Query.SORT.OLDEST_FIRST)
msgs = query.search_messages()
@ -131,11 +210,23 @@ def applyMsgs(queryStr, function, useProgressbar=False):
else:
iterator = msgs
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: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)

View file

@ -9,16 +9,18 @@ import os
import sys
# TODO Find config file from XDG
# TODO Alias adresses
# TODO Signature file
# 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.read(configPath)
storageFull = os.path.realpath(os.path.expanduser(config["GENERAL"]["storage"]))
config["GENERAL"]["storage"] = storageFull
SERVER_DEFAULTS = {
"imap": {"port": 143, "starttls": True},
"smtp": {"port": 587, "starttls": True},
@ -27,6 +29,7 @@ SERVER_ITEMS = {"host", "port", "user", "pass", "starttls"}
# Reading sections
accounts = dict()
mails = set()
for name in config.sections():
if not name.islower():
@ -56,13 +59,23 @@ for name in config.sections():
continue
data[key] = section[key]
data["name"] = name
data["storage"] = os.path.join(config['GLOBAL']['storage'], name)
mails.add(section["from"])
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")
storageFull = os.path.expanduser(data["storage"])
os.makedirs(storageFull, exist_ok=True)
accounts[name] = data
general = dict()
section = config["GENERAL"]
for key in section.keys():
general[key] = section[key]
general["main"] = accounts[general["main"]]
# OfflineIMAP
OFFLINEIMAP_BEGIN = """[general]
@ -82,19 +95,19 @@ footer = "\\n"
"""
OFFLINEIMAP_ACCOUNT = """[Account {name}]
localrepository = {name}-local
remoterepository = {name}-remote
OFFLINEIMAP_ACCOUNT = """[Account {account}]
localrepository = {account}-local
remoterepository = {account}-remote
autorefresh = 0.5
quick = 10
utf8foldernames = yes
postsynchook = ~/.mutt/postsync
[Repository {name}-local]
[Repository {account}-local]
type = Maildir
localfolders = {storage}
[Repository {name}-remote]
[Repository {account}-remote]
type = IMAP
{secconf}
keepalive = 60
@ -116,23 +129,24 @@ for name, account in accounts.items():
# TODO Write
# mbsync
MBSYNC_ACCOUNT = """IMAPAccount {name}
MBSYNC_ACCOUNT = """IMAPAccount {account}
Host {imaphost}
Port {imapport}
User {imapuser}
Pass "{imappass}"
Pass "{imappassEscaped}"
{secconf}
IMAPStore {name}-remote
Account {name}
IMAPStore {account}-remote
Account {account}
MaildirStore {name}-local
MaildirStore {account}-local
Subfolders Verbatim
Path {storage}/
Inbox {storageInbox}/
Channel {name}
Master :{name}-remote:
Slave :{name}-local:
Channel {account}
Master :{account}-remote:
Slave :{account}-local:
Patterns *
Create Both
SyncState *
@ -145,7 +159,10 @@ for name, account in accounts.items():
secconf = "SSLType STARTTLS"
else:
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')
with open(msbsyncFilepath, 'w') as f:
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}
user {smtpuser}
password {smtppass}
@ -174,3 +191,37 @@ for name, account in accounts.items():
msbsyncFilepath = os.path.join(os.path.expanduser('~'), '.msmtprc')
with open(msbsyncFilepath, 'w') as f:
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)