1# Copyright 2013 - 2020 Patrick Ulbrich <zulu99@gmx.net> 2# Copyright 2020 Dan Christensen <jdc@uwo.ca> 3# Copyright 2020 Denis Anuschewski 4# 5# This program is free software; you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation; either version 2 of the License, or 8# (at your option) any later version. 9# 10# This program is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with this program; if not, write to the Free Software 17# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 18# MA 02110-1301, USA. 19# 20 21import gi 22 23gi.require_version('Notify', '0.7') 24gi.require_version('GLib', '2.0') 25gi.require_version('Gtk', '3.0') 26 27import os 28import dbus 29import threading 30from gi.repository import Notify, Gio, Gtk 31from Mailnag.common.plugins import Plugin, HookTypes 32from Mailnag.common.i18n import _ 33from Mailnag.common.subproc import start_subprocess 34from Mailnag.common.exceptions import InvalidOperationException 35 36NOTIFICATION_MODE_COUNT = '0' 37NOTIFICATION_MODE_SHORT_SUMMARY = '3' 38NOTIFICATION_MODE_SUMMARY = '1' 39NOTIFICATION_MODE_SINGLE = '2' 40 41plugin_defaults = { 42 'notification_mode' : NOTIFICATION_MODE_SHORT_SUMMARY, 43 'max_visible_mails' : '10' 44} 45 46 47class LibNotifyPlugin(Plugin): 48 def __init__(self): 49 # dict that tracks all notifications that need to be closed 50 self._notifications = {} 51 self._initialized = False 52 self._lock = threading.Lock() 53 self._notification_server_wait_event = threading.Event() 54 self._notification_server_ready = False 55 self._is_gnome = False 56 self._mails_added_hook = None 57 58 59 def enable(self): 60 self._max_mails = int(self.get_config()['max_visible_mails']) 61 self._notification_server_wait_event.clear() 62 self._notification_server_ready = False 63 self._notifications = {} 64 65 # initialize Notification 66 if not self._initialized: 67 Notify.init("Mailnag") 68 self._is_gnome = self._is_gnome_environment(('XDG_CURRENT_DESKTOP', 'GDMSESSION')) 69 self._initialized = True 70 71 def mails_added_hook(new_mails, all_mails): 72 self._notify_async(new_mails, all_mails) 73 74 self._mails_added_hook = mails_added_hook 75 76 def mails_removed_hook(remaining_mails): 77 self._notify_async([], remaining_mails) 78 79 self._mails_removed_hook = mails_removed_hook 80 81 controller = self.get_mailnag_controller() 82 hooks = controller.get_hooks() 83 84 hooks.register_hook_func(HookTypes.MAILS_ADDED, 85 self._mails_added_hook) 86 87 hooks.register_hook_func(HookTypes.MAILS_REMOVED, 88 self._mails_removed_hook) 89 90 def disable(self): 91 controller = self.get_mailnag_controller() 92 hooks = controller.get_hooks() 93 94 if self._mails_added_hook != None: 95 hooks.unregister_hook_func(HookTypes.MAILS_ADDED, 96 self._mails_added_hook) 97 self._mails_added_hook = None 98 99 if self._mails_removed_hook != None: 100 hooks.unregister_hook_func(HookTypes.MAILS_REMOVED, 101 self._mails_removed_hook) 102 self._mails_removed_hook = None 103 104 # Abort possible notification server wait 105 self._notification_server_wait_event.set() 106 # Close all open notifications 107 # (must be called after _notification_server_wait_event.set() 108 # to prevent a possible deadlock) 109 self._close_notifications() 110 111 112 def get_manifest(self): 113 return (_("LibNotify Notifications"), 114 _("Shows a popup when new mails arrive."), 115 "2.1", 116 "Patrick Ulbrich <zulu99@gmx.net>") 117 118 119 def get_default_config(self): 120 return plugin_defaults 121 122 123 def has_config_ui(self): 124 return True 125 126 127 def get_config_ui(self): 128 radio_mapping = [ 129 (NOTIFICATION_MODE_COUNT, Gtk.RadioButton(label = _('Count of new mails'))), 130 (NOTIFICATION_MODE_SHORT_SUMMARY, Gtk.RadioButton(label = _('Short summary of new mails'))), 131 (NOTIFICATION_MODE_SUMMARY, Gtk.RadioButton(label = _('Detailed summary of new mails'))), 132 (NOTIFICATION_MODE_SINGLE, Gtk.RadioButton(label = _('One notification per new mail'))) 133 ] 134 135 box = Gtk.Box() 136 box.set_spacing(12) 137 box.set_orientation(Gtk.Orientation.VERTICAL) 138 139 label = Gtk.Label() 140 label.set_markup('<b>%s</b>' % _('Notification mode:')) 141 label.set_alignment(0.0, 0.0) 142 box.pack_start(label, False, False, 0) 143 144 inner_box = Gtk.Box() 145 inner_box.set_spacing(6) 146 inner_box.set_orientation(Gtk.Orientation.VERTICAL) 147 148 last_radio = None 149 for m, r in radio_mapping: 150 if last_radio != None: 151 r.join_group(last_radio) 152 inner_box.pack_start(r, False, False, 0) 153 last_radio = r 154 155 alignment = Gtk.Alignment() 156 alignment.set_padding(0, 6, 18, 0) 157 alignment.add(inner_box) 158 box.pack_start(alignment, False, False, 0) 159 160 box._radio_mapping = radio_mapping 161 162 return box 163 164 165 def load_ui_from_config(self, config_ui): 166 config = self.get_config() 167 radio = [ r for m, r in config_ui._radio_mapping if m == config['notification_mode'] ][0] 168 radio.set_active(True) 169 170 171 def save_ui_to_config(self, config_ui): 172 config = self.get_config() 173 mode = [ m for m, r in config_ui._radio_mapping if r.get_active() ] [0] 174 config['notification_mode'] = mode 175 176 177 def _notify_async(self, new_mails, all_mails): 178 def thread(): 179 with self._lock: 180 # The desktop session may have started Mailnag 181 # before the libnotify dbus daemon. 182 if not self._notification_server_ready: 183 if not self._wait_for_notification_server(): 184 return 185 self._notification_server_ready = True 186 187 config = self.get_config() 188 if config['notification_mode'] == NOTIFICATION_MODE_SINGLE: 189 self._notify_single(new_mails, all_mails) 190 else: 191 if len(all_mails) == 0: 192 if '0' in self._notifications: 193 # The user may have closed the notification: 194 try_close(self._notifications['0']) 195 del self._notifications['0'] 196 elif len(new_mails) > 0: 197 if config['notification_mode'] == NOTIFICATION_MODE_COUNT: 198 self._notify_count(len(all_mails)) 199 elif config['notification_mode'] == NOTIFICATION_MODE_SHORT_SUMMARY: 200 self._notify_short_summary(new_mails, all_mails) 201 elif config['notification_mode'] == NOTIFICATION_MODE_SUMMARY: 202 self._notify_summary(new_mails, all_mails) 203 204 t = threading.Thread(target = thread) 205 t.start() 206 207 208 def _notify_short_summary(self, new_mails, all_mails): 209 summary = "" 210 body = "" 211 lst = [] 212 mails = self._prepend_new_mails(new_mails, all_mails) 213 mail_count = len(mails) 214 215 if len(self._notifications) == 0: 216 self._notifications['0'] = self._get_notification(" ", None, None) # empty string will emit a gtk warning 217 218 i = 0 219 n = 0 220 while (n < 3) and (i < mail_count): 221 s = self._get_sender(mails[i]) 222 if s not in lst: 223 lst.append(s) 224 n += 1 225 i += 1 226 227 if self._is_gnome: 228 senders = "<i>%s</i>" % ", ".join(lst) 229 else: 230 senders = ", ".join(lst) 231 232 if mail_count > 1: 233 summary = _("{0} new mails").format(str(mail_count)) 234 if (mail_count - i) > 1: 235 body = _("from {0} and others.").format(senders) 236 else: 237 body = _("from {0}.").format(senders) 238 else: 239 summary = _("New mail") 240 body = _("from {0}.").format(senders) 241 242 self._notifications['0'].update(summary, body, "mail-unread") 243 self._notifications['0'].show() 244 245 246 def _notify_summary(self, new_mails, all_mails): 247 summary = "" 248 body = "" 249 mails = self._prepend_new_mails(new_mails, all_mails) 250 251 if len(self._notifications) == 0: 252 self._notifications['0'] = self._get_notification(" ", None, None) # empty string will emit a gtk warning 253 254 ubound = len(mails) if len(mails) <= self._max_mails else self._max_mails 255 256 for i in range(ubound): 257 if self._is_gnome: 258 body += "%s:\n<i>%s</i>\n\n" % (self._get_sender(mails[i]), mails[i].subject) 259 else: 260 body += "%s - %s\n" % (ellipsize(self._get_sender(mails[i]), 20), ellipsize(mails[i].subject, 20)) 261 262 if len(mails) > self._max_mails: 263 if self._is_gnome: 264 body += "<i>%s</i>" % _("(and {0} more)").format(str(len(mails) - self._max_mails)) 265 else: 266 body += _("(and {0} more)").format(str(len(mails) - self._max_mails)) 267 268 if len(mails) > 1: # multiple new emails 269 summary = _("{0} new mails").format(str(len(mails))) 270 else: 271 summary = _("New mail") 272 273 self._notifications['0'].update(summary, body, "mail-unread") 274 self._notifications['0'].show() 275 276 277 def _notify_single(self, new_mails, all_mails): 278 # Remove notifications for messages not in all_mails: 279 for k, n in list(self._notifications.items()): 280 if hasattr(n, 'mail') and not (n.mail in all_mails): 281 # The user may have closed the notification: 282 try_close(n) 283 del self._notifications[k] 284 285 # In single notification mode new mails are 286 # added to the *bottom* of the notification list. 287 new_mails.sort(key = lambda m: m.datetime, reverse = False) 288 289 for mail in new_mails: 290 n = self._get_notification(self._get_sender(mail), mail.subject, "mail-unread") 291 # Remember the associated message, so we know when to remove the notification: 292 n.mail = mail 293 notification_id = str(id(n)) 294 if self._is_gnome: 295 n.add_action("mark-as-read", _("Mark as read"), 296 self._notification_action_handler, (mail, notification_id)) 297 n.show() 298 self._notifications[notification_id] = n 299 300 301 def _notify_count(self, count): 302 if len(self._notifications) == 0: 303 self._notifications['0'] = self._get_notification(" ", None, None) # empty string will emit a gtk warning 304 305 if count > 1: # multiple new emails 306 summary = _("{0} new mails").format(str(count)) 307 else: 308 summary = _("New mail") 309 310 self._notifications['0'].update(summary, None, "mail-unread") 311 self._notifications['0'].show() 312 313 314 def _close_notifications(self): 315 with self._lock: 316 for n in self._notifications.values(): 317 try_close(n) 318 self._notifications = {} 319 320 321 def _get_notification(self, summary, body, icon): 322 n = Notify.Notification.new(summary, body, icon) 323 n.set_category("email") 324 n.set_hint_string("desktop-entry", "mailnag") 325 326 if self._is_gnome: 327 n.add_action("default", "default", self._notification_action_handler, None) 328 329 return n 330 331 332 def _wait_for_notification_server(self): 333 bus = dbus.SessionBus() 334 while not bus.name_has_owner('org.freedesktop.Notifications'): 335 self._notification_server_wait_event.wait(5) 336 if self._notification_server_wait_event.is_set(): 337 return False 338 return True 339 340 341 def _notification_action_handler(self, n, action, user_data): 342 with self._lock: 343 if action == "default": 344 mailclient = get_default_mail_reader() 345 if mailclient != None: 346 start_subprocess(mailclient) 347 348 # clicking the notification bubble has closed all notifications 349 # so clear the reference array as well. 350 self._notifications = {} 351 elif action == "mark-as-read": 352 controller = self.get_mailnag_controller() 353 try: 354 controller.mark_mail_as_read(user_data[0].id) 355 except InvalidOperationException: 356 pass 357 358 # clicking the action has closed the notification 359 # so remove its reference. 360 del self._notifications[user_data[1]] 361 362 363 def _get_sender(self, mail): 364 name, addr = mail.sender 365 if len(name) > 0: return name 366 else: return addr 367 368 369 def _prepend_new_mails(self, new_mails, all_mails): 370 # The mail list (all_mails) is sorted by date (mails with most recent 371 # date on top). New mails with no date or older mails that come in 372 # delayed won't be listed on top. So if a mail with no or an older date 373 # arrives, it gives the impression that the top most mail (i.e. the mail 374 # with the most recent date) is re-notified. 375 # To fix that, simply put new mails on top explicitly. 376 return new_mails + [m for m in all_mails if m not in new_mails] 377 378 379 def _is_gnome_environment(self, env_vars): 380 for var in env_vars: 381 if 'gnome' in os.environ.get(var, '').lower().split(':'): 382 return True 383 return False 384 385 386def get_default_mail_reader(): 387 mail_reader = None 388 app_info = Gio.AppInfo.get_default_for_type ("x-scheme-handler/mailto", False) 389 390 if app_info != None: 391 executable = Gio.AppInfo.get_executable(app_info) 392 393 if (executable != None) and (len(executable) > 0): 394 mail_reader = executable 395 396 return mail_reader 397 398 399def ellipsize(str, max_len): 400 if max_len < 3: max_len = 3 401 if len(str) <= max_len: 402 return str 403 else: 404 return str[0:max_len - 3] + '...' 405 406 407# If the user has closed the notification, an exception is raised. 408def try_close(notification): 409 try: 410 notification.close() 411 except: 412 pass 413