├── notifymuch ├── __init__.py ├── config.py ├── notification.py └── messages.py ├── setup.py ├── bin └── notifymuch └── README.rst /notifymuch/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup 3 | 4 | 5 | def readme(): 6 | with open("README.rst", "r") as f: 7 | return f.read() 8 | 9 | 10 | setup( 11 | name="notifymuch", 12 | version="0.1", 13 | description="Display desktop notifications for unread mail in notmuch " 14 | "database", 15 | long_description=readme(), 16 | author="Kiprianas Spiridonovas", 17 | author_email="k.spiridonovas@gmail.com", 18 | url="https://github.com/kspi/notifymuch", 19 | license="GPL3", 20 | packages=['notifymuch'], 21 | scripts=["bin/notifymuch"], 22 | 23 | install_requires=[ 24 | "notmuch", 25 | "pygobject", 26 | ], 27 | ) 28 | -------------------------------------------------------------------------------- /notifymuch/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import configparser 3 | from gi.repository import GLib 4 | 5 | 6 | __all__ = ['load', 'get'] 7 | 8 | 9 | CONFIG_DIR = os.path.join(GLib.get_user_config_dir(), 'notifymuch') 10 | CONFIG_FILE = os.path.join(CONFIG_DIR, 'notifymuch.cfg') 11 | 12 | DEFAULT_CONFIG = { 13 | 'query': 'is:unread and is:inbox', 14 | 'mail_client': 'gnome-terminal -x mutt -y', 15 | 'recency_interval_hours': '48', 16 | 'hidden_tags': 'inbox unread attachment replied sent encrypted signed', 17 | } 18 | 19 | CONFIG = configparser.ConfigParser() 20 | 21 | 22 | def load(): 23 | global CONFIG 24 | CONFIG['notifymuch'] = DEFAULT_CONFIG 25 | if not CONFIG.read(CONFIG_FILE): 26 | os.makedirs(CONFIG_DIR, exist_ok=True) 27 | with open(CONFIG_FILE, "w") as f: 28 | CONFIG.write(f) 29 | 30 | 31 | def get(option): 32 | return CONFIG['notifymuch'][option] 33 | -------------------------------------------------------------------------------- /bin/notifymuch: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | import argparse 5 | 6 | parser = argparse.ArgumentParser( 7 | description=''' 8 | Display desktop notifications for unread mail in notmuch database. 9 | ''') 10 | parser.set_defaults(print_summary=False) 11 | parser.add_argument('--print-summary', dest='print_summary', 12 | action='store_true', 13 | help=""" 14 | Just print the query summary to stdout, without recency 15 | tracking. 16 | """) 17 | args = parser.parse_args() 18 | 19 | if args.print_summary: 20 | from notifymuch.messages import Messages 21 | from notifymuch import config 22 | config.load() 23 | summary = Messages().summary() 24 | if summary: 25 | print(summary) 26 | else: 27 | # Background ourselves for consistent behaviour, because show_notification 28 | # may or may not block. 29 | pid = os.fork() 30 | if pid > 0: 31 | os._exit(0) 32 | 33 | from notifymuch.notification import show_notification 34 | show_notification() 35 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | notifymuch 2 | ========== 3 | 4 | This is a simple program that displays desktop notifications for unread 5 | mail (or actually any search query) in the notmuch database. The notification 6 | can optionally have a button to run a mail client. 7 | 8 | .. image:: http://i.imgur.com/F3uAQmt.png 9 | :alt: Screenshot of notifymuch 10 | 11 | When a message is shown in a notification, it is internally marked as 'recently 12 | seen' and not shown again for two days (configurable). 13 | 14 | 15 | Installation and usage 16 | ---------------------- 17 | 18 | The program requires Python 3, setuptools, pygobject and notmuch. 19 | It can be installed together with its dependencies using:: 20 | 21 | pip install . 22 | 23 | To use, execute ``notifymuch`` after new mail is indexed (for example in a 24 | *post-new* hook). The program forks and stays in the background while the 25 | notification is active. If upon launch a notification is already active, it 26 | is updated. 27 | 28 | 29 | Configuration 30 | ------------- 31 | 32 | Configuration is stored in ``~/.config/notifymuch/notifymuch.cfg``, 33 | which is created on first run. Settings that can be set there: 34 | 35 | query 36 | The notmuch search query for the messages. Default is 37 | ``is:unread and is:inbox``. 38 | 39 | mail_client 40 | The command to launch the preferred mail client. If empty, the button 41 | isn't shown. Default is ``gnome-terminal -x mutt -y``. 42 | 43 | recency_interval_hours 44 | Each message is notified about at most once in this time interval. Default is 45 | ``48``. 46 | 47 | hidden_tags 48 | Tag names that are not shown in the notification. Default is 49 | ``inbox unread attachment replied sent encrypted signed``. 50 | -------------------------------------------------------------------------------- /notifymuch/notification.py: -------------------------------------------------------------------------------- 1 | import shlex 2 | import subprocess 3 | 4 | import gi 5 | 6 | gi.require_version ('Notify', '0.7') 7 | gi.require_version ('Gtk', '3.0') 8 | 9 | from gi.repository import Notify, Gio, Gtk 10 | 11 | from notifymuch import config 12 | from notifymuch.messages import Messages 13 | 14 | 15 | __all__ = ["show_notification"] 16 | 17 | 18 | class NotifymuchNotification(Gio.Application): 19 | ICON = 'mail-unread-symbolic' 20 | ICON_SIZE = 64 21 | 22 | def __init__(self): 23 | Gio.Application.__init__( 24 | self, 25 | application_id="net.wemakethings.Notifymuch") 26 | self.connect('startup', self.on_startup) 27 | self.connect('activate', self.on_activate) 28 | 29 | def on_startup(self, data): 30 | config.load() 31 | Notify.init('notifymuch') 32 | 33 | # Use GTK to look up the icon to properly fallback to 'mail-unread'. 34 | icon = Gtk.IconTheme.get_default().lookup_icon( 35 | self.ICON, 36 | self.ICON_SIZE, 37 | 0) 38 | self.icon_filename = icon.get_filename() 39 | 40 | self.notification = Notify.Notification.new('', '', self.icon_filename) 41 | self.notification.set_category('email.arrived') 42 | if config.get("mail_client"): 43 | self.notification.add_action( 44 | 'mail-client', 45 | 'Run mail client', 46 | self.action_mail_client) 47 | self.notification.connect('closed', lambda e: self.quit()) 48 | self.hold() 49 | 50 | def on_activate(self, data): 51 | config.load() # Reload config on each update. 52 | messages = Messages() 53 | summary = messages.unseen_summary() 54 | if summary == "": 55 | self.release() 56 | else: 57 | self.notification.update( 58 | summary="{count} unread messages".format( 59 | count=messages.count()), 60 | body=summary, 61 | icon=self.icon_filename) 62 | self.notification.show() 63 | 64 | def action_mail_client(self, action, data): 65 | self.release() 66 | subprocess.Popen(shlex.split(config.get("mail_client"))) 67 | 68 | 69 | def show_notification(): 70 | """If a notification is open already, asks it to update itself and returns 71 | immediately. Otherwise opens a notification and blocks until it is 72 | closed.""" 73 | NotifymuchNotification().run() 74 | -------------------------------------------------------------------------------- /notifymuch/messages.py: -------------------------------------------------------------------------------- 1 | from email.utils import parseaddr 2 | import os 3 | import time 4 | import shelve 5 | import notmuch 6 | from gi.repository import GLib 7 | from notifymuch import config 8 | 9 | 10 | __all__ = ["Messages"] 11 | 12 | 13 | CACHE_DIR = os.path.join(GLib.get_user_cache_dir(), 'notifymuch') 14 | LAST_SEEN_FILE = os.path.join(CACHE_DIR, 'last_seen') 15 | 16 | 17 | def exclude_recently_seen(messages): 18 | os.makedirs(CACHE_DIR, exist_ok=True) 19 | recency_interval = int(config.get('recency_interval_hours')) * 60 * 60 20 | with shelve.open(LAST_SEEN_FILE) as last_seen: 21 | now = time.time() 22 | for k in last_seen.keys(): 23 | if now - last_seen[k] > recency_interval: 24 | del last_seen[k] 25 | for message in messages: 26 | m_id = message.get_message_id() 27 | if m_id not in last_seen: 28 | last_seen[m_id] = now 29 | yield message 30 | 31 | 32 | def filter_tags(ts): 33 | hidden_tags = frozenset(config.get('hidden_tags').split(' ')) 34 | for t in ts: 35 | if t not in hidden_tags: 36 | yield t 37 | 38 | 39 | def ellipsize(text, length=80): 40 | if len(text) > length: 41 | return text[:length - 1] + '…' 42 | else: 43 | return text 44 | 45 | 46 | def pretty_date(time=None): 47 | """ 48 | Get a datetime object or a int() Epoch timestamp and return a 49 | pretty string like 'an hour ago', 'yesterday', '3 months ago', 50 | 'just now', etc 51 | """ 52 | from datetime import datetime 53 | now = datetime.now() 54 | if type(time) is int: 55 | diff = now - datetime.fromtimestamp(time) 56 | elif isinstance(time, datetime): 57 | diff = now - time 58 | elif not time: 59 | diff = now - now 60 | second_diff = diff.seconds 61 | day_diff = diff.days 62 | 63 | if day_diff < 0: 64 | return '' 65 | 66 | def ago(number, unit): 67 | if number == 1: 68 | return "a {unit} ago".format(unit=unit) 69 | else: 70 | return "{number} {unit}s ago".format( 71 | number=round(number), 72 | unit=unit) 73 | 74 | if day_diff == 0: 75 | if second_diff < 10: 76 | return "just now" 77 | if second_diff < 60: 78 | return ago(second_diff, "second") 79 | if second_diff < 120: 80 | return "a minute ago" 81 | if second_diff < 3600: 82 | return ago(second_diff / 60, "minute") 83 | if second_diff < 7200: 84 | return "an hour ago" 85 | if second_diff < 86400: 86 | return ago(second_diff / 60 / 60, "hour") 87 | if day_diff == 1: 88 | return "yesterday" 89 | if day_diff < 7: 90 | return ago(day_diff, "day") 91 | if day_diff < 31: 92 | return ago(day_diff / 7, "week") 93 | if day_diff < 365: 94 | return ago(day_diff / 30, "month") 95 | return ago(day_diff / 365, "year") 96 | 97 | 98 | def pretty_sender(fromline): 99 | name, addr = parseaddr(fromline) 100 | return name or addr 101 | 102 | 103 | def tags_prefix(tags): 104 | tags = list(tags) 105 | if tags: 106 | return '[{tags}] '.format(tags=' '.join(tags)) 107 | else: 108 | return '' 109 | 110 | 111 | def summary(messages): 112 | return '\n'.join('{tags}{subject} ({sender}, {date})'.format( 113 | subject=ellipsize(message.get_header('subject')), 114 | sender=pretty_sender(message.get_header('from')), 115 | date=pretty_date(message.get_date()), 116 | tags=tags_prefix(filter_tags(message.get_tags()))) 117 | for message in messages) 118 | 119 | 120 | class Messages: 121 | def __init__(self): 122 | db = notmuch.Database() 123 | self.query = notmuch.Query(db, config.get('query')) 124 | self.query.set_sort(notmuch.Query.SORT.OLDEST_FIRST) 125 | 126 | def count(self): 127 | return self.query.count_messages() 128 | 129 | def messages(self): 130 | return self.query.search_messages() 131 | 132 | def summary(self): 133 | return summary(self.messages()) 134 | 135 | def unseen_messages(self): 136 | return exclude_recently_seen(self.messages()) 137 | 138 | def unseen_summary(self): 139 | return summary(self.unseen_messages()) 140 | --------------------------------------------------------------------------------