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