offlineimap_notify.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. #!/usr/bin/python2
  2. # Copyright (C) 2013 Raymond Wagenmaker <raymondwagenmaker@gmail.com>
  3. # Copyright (C) 2020 Distopico <distopico@riseup.net> and contributors
  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 3 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, see <http://www.gnu.org/licenses/>.
  17. """Run OfflineIMAP after adding notification sending to its UIs.
  18. When an account finishes syncing, messages copied to the local repository will
  19. be reported using D-Bus (through notify2) or a fallback notifier command.
  20. """
  21. import cgi
  22. from collections import defaultdict, OrderedDict
  23. import ConfigParser
  24. from datetime import datetime
  25. import email.header
  26. import email.parser
  27. import email.utils
  28. import functools
  29. import inspect
  30. import locale
  31. import operator
  32. import os
  33. import shlex
  34. import string
  35. import subprocess
  36. import sys
  37. import textwrap
  38. import offlineimap
  39. try:
  40. import notify2
  41. except ImportError:
  42. pass
  43. __copyright__ = """
  44. Copyright 2013, Raymond Wagenmaker <raymondwagenmaker@gmail.com>
  45. Copyright 2020, Distopico <distopico@riseup.net>
  46. """
  47. __author__ = 'Raymond Wagenmaker and Distopico'
  48. __maintainer__ = 'Distopico <distopico@riseup.net>'
  49. __license__ = "GPLv3"
  50. __version__ = '0.6.0'
  51. CONFIG_SECTION = 'notifications'
  52. CONFIG_DEFAULTS = OrderedDict((
  53. ('summary', 'New mail for {account} in {folder}'),
  54. ('body', 'From: {h[from]}\nSubject: {h[subject]}'),
  55. ('icon', 'mail-unread'),
  56. ('urgency', 'normal'),
  57. ('timeout', '-1'),
  58. ('max', '2'),
  59. ('digest-summary', 'New mail for {account} ({count})'),
  60. ('digest-body', '{count} in {folder}'),
  61. ('notifier', 'notify-send -a {appname} -i {icon} -c {category}'
  62. ' -u {urgency} -t {timeout} {summary} {body}'),
  63. ('failstr', '')
  64. ))
  65. def send_notification(ui, conf, summary, body):
  66. appname = 'OfflineIMAP'
  67. category = 'email.arrived'
  68. encode = functools.partial(unicode.encode, errors='replace')
  69. try:
  70. notify2.init(appname)
  71. notification = notify2.Notification(encode(unicode(summary), 'utf-8'),
  72. encode(unicode(body), 'utf-8'),
  73. icon.encode('utf-8'))
  74. notification.set_category(category)
  75. notification.set_urgency(conf['urgency'])
  76. notification.set_timeout(conf['timeout'])
  77. notification.show()
  78. except (NameError, RuntimeError): # no notify2 or no notification service
  79. try:
  80. format_args = {'appname': appname, 'category': category,
  81. 'summary': summary, 'body': body, 'icon': conf['icon'],
  82. 'urgency': conf['urgency'], 'timeout': conf['timeout']}
  83. encoding = locale.getpreferredencoding(False)
  84. subprocess.call([encode(word.decode(encoding).format(**format_args),
  85. encoding)
  86. for word in shlex.split(conf['notifier'])])
  87. except ValueError as exc:
  88. ui.error(exc, msg='While parsing fallback notifier command')
  89. except OSError as exc:
  90. ui.error(exc, msg='While calling fallback notifier')
  91. def add_notifications(ui_cls):
  92. def extension(method):
  93. old = getattr(ui_cls, method.__name__)
  94. uibase_spec = inspect.getargspec(getattr(offlineimap.ui.UIBase.UIBase,
  95. method.__name__))
  96. @functools.wraps(old)
  97. def new(*args, **kwargs):
  98. old(*args, **kwargs)
  99. old_args = inspect.getcallargs(old, *args, **kwargs)
  100. method(**{arg: old_args[arg] for arg in uibase_spec.args})
  101. setattr(ui_cls, method.__name__, new)
  102. @extension
  103. def __init__(self, *args, **kwargs):
  104. self.local_repo_names = {}
  105. self.new_messages = defaultdict(lambda: defaultdict(list))
  106. @extension
  107. def acct(self, account):
  108. self.local_repo_names[account] = account.localrepos.getname()
  109. @extension
  110. def acctdone(self, account):
  111. if self.new_messages[account]:
  112. notify(self, account)
  113. self.new_messages[account].clear()
  114. @extension
  115. def copyingmessage(self, uid, num, num_to_copy, src, destfolder):
  116. repository = destfolder.getrepository()
  117. account = repository.getaccount()
  118. if (repository.getname() == self.local_repo_names[account] and
  119. 'S' not in src.getmessageflags(uid)):
  120. content = { 'uid': uid, 'message': src.getmessage(uid) }
  121. folder = destfolder.getname()
  122. self.new_messages[account][folder].append(content)
  123. return ui_cls
  124. class MailNotificationFormatter(string.Formatter):
  125. _FAILED_DATE_CONVERSION = object()
  126. def __init__(self, escape=False, failstr=''):
  127. self.escape = escape
  128. self.failstr = failstr
  129. def convert_field(self, value, conversion):
  130. if conversion == 'd':
  131. datetuple = email.utils.parsedate_tz(value)
  132. if datetuple is None:
  133. return MailNotificationFormatter._FAILED_DATE_CONVERSION
  134. return datetime.fromtimestamp(email.utils.mktime_tz(datetuple))
  135. elif conversion in ('a', 'n', 'N'):
  136. name, address = email.utils.parseaddr(value)
  137. if not address:
  138. address = value
  139. if conversion == 'a':
  140. return address
  141. return name if name or conversion == 'n' else address
  142. return super(MailNotificationFormatter, self).convert_field(value,
  143. conversion)
  144. def format_field(self, value, format_spec):
  145. if value is MailNotificationFormatter._FAILED_DATE_CONVERSION:
  146. result = self.failstr
  147. else:
  148. result = super(MailNotificationFormatter, self).format_field(value,
  149. format_spec)
  150. return cgi.escape(result, quote=True) if self.escape else result
  151. class HeaderDecoder(object):
  152. def __init__(self, message, failstr=''):
  153. self.message = message
  154. self.failstr = failstr
  155. def __getitem__(self, key):
  156. header = self.message[key]
  157. if header is None:
  158. return self.failstr
  159. return ' '.join(word.decode(charset, errors='replace')
  160. if charset is not None else word
  161. for word, charset in email.header.decode_header(header))
  162. def get_config(ui):
  163. conf = CONFIG_DEFAULTS.copy()
  164. decode = operator.methodcaller('decode', locale.getpreferredencoding(False))
  165. try:
  166. for item in ui.config.items(CONFIG_SECTION):
  167. option, value = map(decode, item)
  168. if option in ('max', 'timeout'):
  169. try:
  170. conf[option] = int(value)
  171. except ValueError:
  172. ui.warn('value "{}" for "{}" is not a valid integer; '
  173. 'ignoring'.format(value, option))
  174. else:
  175. conf[option] = value
  176. except ConfigParser.NoSectionError:
  177. pass
  178. return conf
  179. def notify(ui, account):
  180. encoding = locale.getpreferredencoding(False)
  181. account_name = account.getname().decode(encoding)
  182. conf = get_config(ui)
  183. notify_send = functools.partial(send_notification, ui, conf)
  184. summary_formatter = MailNotificationFormatter(escape=False, failstr=conf['failstr'])
  185. body_formatter = MailNotificationFormatter(escape=True, failstr=conf['failstr'])
  186. count = 0
  187. body = []
  188. for folder, contents in ui.new_messages[account].iteritems():
  189. count += len(contents)
  190. body.append(body_formatter.format(conf['digest-body'], count=len(contents),
  191. folder=folder))
  192. if count > conf['max']:
  193. summary = summary_formatter.format(conf['digest-summary'], count=count,
  194. account=account_name)
  195. return notify_send(summary, '\n'.join(body))
  196. need_body = '{body' in conf['body'] or '{body' in conf['summary']
  197. parser = email.parser.Parser()
  198. for folder, contents in ui.new_messages[account].iteritems():
  199. format_args = {'account': account_name,
  200. 'folder': folder.decode(encoding)}
  201. for content in contents:
  202. message = parser.parsestr(content.get('message'),
  203. headersonly=not need_body)
  204. format_args['h'] = HeaderDecoder(message, failstr=conf['failstr'])
  205. if need_body:
  206. for part in message.walk():
  207. if part.get_content_type() == 'text/plain':
  208. charset = part.get_content_charset()
  209. payload = part.get_payload(decode=True)
  210. format_args['body'] = payload.decode(charset)
  211. break
  212. else:
  213. format_args['body'] = conf['failstr']
  214. try:
  215. notify_send(summary_formatter.vformat(conf['summary'], (), format_args),
  216. body_formatter.vformat(conf['body'], (), format_args))
  217. except (AttributeError, KeyError, TypeError, ValueError) as exc:
  218. ui.error(exc, msg='In notification format specification')
  219. def print_help():
  220. try:
  221. text_width = int(os.environ['COLUMNS'])
  222. except (KeyError, ValueError):
  223. text_width = 80
  224. tw = textwrap.TextWrapper(width=text_width)
  225. print('Notification wrapper v{} -- {}\n'.format(__version__, __copyright__))
  226. print(tw.fill(__doc__))
  227. print('\nDefault configuration:\n')
  228. default_config = offlineimap.CustomConfig.CustomConfigParser()
  229. default_config.add_section(CONFIG_SECTION)
  230. for option, value in CONFIG_DEFAULTS.iteritems():
  231. default_config.set(CONFIG_SECTION, option, value)
  232. default_config.write(sys.stdout)
  233. def main():
  234. locale.setlocale(locale.LC_ALL, '')
  235. for name, cls in offlineimap.ui.UI_LIST.iteritems():
  236. offlineimap.ui.UI_LIST[name] = add_notifications(cls)
  237. try:
  238. offlineimap.OfflineImap().run()
  239. except SystemExit:
  240. if '-h' in sys.argv or '--help' in sys.argv:
  241. print('\n')
  242. print_help()
  243. raise
  244. if __name__ == '__main__':
  245. main()