mel base
This commit is contained in:
		
							parent
							
								
									86bc146125
								
							
						
					
					
						commit
						18a7278422
					
				
					 2 changed files with 188 additions and 46 deletions
				
			
		
							
								
								
									
										139
									
								
								scripts/mel
									
										
									
									
									
								
							
							
						
						
									
										139
									
								
								scripts/mel
									
										
									
									
									
								
							|  | @ -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) | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue