offlineimap_notify.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. #!/usr/bin/python3
  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 notifypy) or a fallback notifier command.
  20. """
  21. import html
  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 notifypy
  41. except ImportError:
  42. pass
  43. __copyright__ = """
  44. Copyright 2013, Raymond Wagenmaker <raymondwagenmaker@gmail.com>
  45. Copyright 2024, Distopico <distopico@riseup.net>
  46. """
  47. __author__ = 'Raymond Wagenmaker and Distopico'
  48. __maintainer__ = 'Distopico <distopico@riseup.net>'
  49. __license__ = "GPLv3"
  50. __version__ = '0.7.1'
  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. icon = conf['icon']
  69. urgency = conf['urgency']
  70. timeout = conf['timeout']
  71. encoding = locale.getpreferredencoding(False)
  72. if not icon or not os.path.isfile(icon):
  73. icon = os.path.join(os.getcwd(), 'icon.svg')
  74. try:
  75. notification = notifypy.Notify()
  76. notification.application_name = appname
  77. notification.title = summary
  78. notification.message = body
  79. notification.icon = icon
  80. notification.urgency = urgency
  81. notification.timeout = timeout
  82. notification.send()
  83. except (NameError, RuntimeError) as e: # no notify-py or no notification service
  84. try:
  85. format_args = {'appname': appname, 'category': category,
  86. 'summary': summary, 'body': body, 'icon': icon,
  87. 'urgency': urgency, 'timeout': timeout}
  88. subprocess.call([word.format(**format_args).encode(encoding)
  89. for word in shlex.split(conf['notifier'])])
  90. except ValueError as exc:
  91. ui.error(exc, msg='While parsing fallback notifier command')
  92. except OSError as exc:
  93. ui.error(exc, msg='While calling fallback notifier')
  94. def add_notifications(ui_cls):
  95. def extension(method):
  96. old = getattr(ui_cls, method.__name__)
  97. uibase_spec = inspect.getfullargspec(getattr(offlineimap.ui.UIBase.UIBase,
  98. method.__name__))
  99. @functools.wraps(old)
  100. def new(*args, **kwargs):
  101. old(*args, **kwargs)
  102. old_args = inspect.getcallargs(old, *args, **kwargs)
  103. method(**{arg: old_args[arg] for arg in uibase_spec.args})
  104. setattr(ui_cls, method.__name__, new)
  105. @extension
  106. def __init__(self, *args, **kwargs):
  107. self.local_repo_names = {}
  108. self.new_messages = defaultdict(lambda: defaultdict(list))
  109. @extension
  110. def acct(self, account):
  111. self.local_repo_names[account] = account.localrepos.getname()
  112. @extension
  113. def acctdone(self, account):
  114. if self.new_messages[account]:
  115. notify(self, account)
  116. self.new_messages[account].clear()
  117. @extension
  118. def copyingmessage(self, uid, num, num_to_copy, src, destfolder):
  119. repository = destfolder.getrepository()
  120. account = repository.getaccount()
  121. if (repository.getname() == self.local_repo_names[account] and
  122. 'S' not in src.getmessageflags(uid)):
  123. content = { 'uid': uid, 'message': src.getmessage(uid) }
  124. folder = destfolder.getname()
  125. self.new_messages[account][folder].append(content)
  126. return ui_cls
  127. class MailNotificationFormatter(string.Formatter):
  128. _FAILED_DATE_CONVERSION = object()
  129. def __init__(self, escape=False, failstr=''):
  130. self.escape = escape
  131. self.failstr = failstr
  132. def convert_field(self, value, conversion):
  133. if conversion == 'd':
  134. datetuple = email.utils.parsedate_tz(value)
  135. if datetuple is None:
  136. return MailNotificationFormatter._FAILED_DATE_CONVERSION
  137. return datetime.fromtimestamp(email.utils.mktime_tz(datetuple))
  138. elif conversion in ('a', 'n', 'N'):
  139. name, address = email.utils.parseaddr(value)
  140. if not address:
  141. address = value
  142. if conversion == 'a':
  143. return address
  144. return name if name or conversion == 'n' else address
  145. return super(MailNotificationFormatter, self).convert_field(value,
  146. conversion)
  147. def format_field(self, value, format_spec):
  148. if value is MailNotificationFormatter._FAILED_DATE_CONVERSION:
  149. result = self.failstr
  150. else:
  151. result = super(MailNotificationFormatter, self).format_field(value,
  152. format_spec)
  153. return html.escape(result, quote=True) if self.escape else result
  154. class HeaderDecoder(object):
  155. def __init__(self, message, failstr=''):
  156. self.message = message
  157. self.failstr = failstr
  158. def __getitem__(self, key):
  159. header = self.message[key]
  160. if header is None:
  161. return self.failstr
  162. return ' '.join(word.decode(charset, errors='replace')
  163. if charset is not None else word
  164. for word, charset in email.header.decode_header(header))
  165. def get_config(ui):
  166. conf = CONFIG_DEFAULTS.copy()
  167. try:
  168. for item in ui.config.items(CONFIG_SECTION):
  169. option, value = item
  170. if option in ('max', 'timeout'):
  171. try:
  172. conf[option] = int(value)
  173. except ValueError:
  174. ui.warn('value "{}" for "{}" is not a valid integer; '
  175. 'ignoring'.format(value, option))
  176. else:
  177. conf[option] = value
  178. except configparser.NoSectionError:
  179. pass
  180. return conf
  181. def notify(ui, account):
  182. account_name = account.getname()
  183. conf = get_config(ui)
  184. notify_send = functools.partial(send_notification, ui, conf)
  185. summary_formatter = MailNotificationFormatter(escape=False, failstr=conf['failstr'])
  186. body_formatter = MailNotificationFormatter(escape=True, failstr=conf['failstr'])
  187. count = 0
  188. body = []
  189. for folder, contents in ui.new_messages[account].items():
  190. count += len(contents)
  191. body.append(body_formatter.format(conf['digest-body'], count=len(contents),
  192. folder=folder))
  193. if count > conf['max']:
  194. summary = summary_formatter.format(conf['digest-summary'], count=count,
  195. account=account_name)
  196. return notify_send(summary, '\n'.join(body))
  197. need_body = '{body' in conf['body'] or '{body' in conf['summary']
  198. parser = email.parser.Parser()
  199. encoding = locale.getpreferredencoding(False)
  200. for folder, contents in ui.new_messages[account].items():
  201. format_args = {'account': account_name,
  202. 'folder': folder}
  203. for content in contents:
  204. message = parser.parsestr(content.get('message').as_string(),
  205. headersonly=not need_body)
  206. format_args['h'] = HeaderDecoder(message, failstr=conf['failstr'])
  207. if need_body:
  208. for part in message.walk():
  209. if part.get_content_type() == 'text/plain':
  210. charset = part.get_content_charset()
  211. payload = part.get_payload(decode=True)
  212. format_args['body'] = payload.decode(charset)
  213. break
  214. else:
  215. format_args['body'] = conf['failstr']
  216. try:
  217. notify_send(summary_formatter.vformat(conf['summary'], (), format_args),
  218. body_formatter.vformat(conf['body'], (), format_args))
  219. except (AttributeError, KeyError, TypeError, ValueError) as exc:
  220. ui.error(exc, msg='In notification format specification')
  221. def print_help():
  222. try:
  223. text_width = int(os.environ['COLUMNS'])
  224. except (KeyError, ValueError):
  225. text_width = 80
  226. tw = textwrap.TextWrapper(width=text_width)
  227. print('Notification wrapper v{} -- {}\n'.format(__version__, __copyright__))
  228. print(tw.fill(__doc__))
  229. print('\nDefault configuration:\n')
  230. default_config = offlineimap.CustomConfig.CustomConfigParser()
  231. default_config.add_section(CONFIG_SECTION)
  232. for option, value in CONFIG_DEFAULTS.items():
  233. default_config.set(CONFIG_SECTION, option, value)
  234. default_config.write(sys.stdout)
  235. def main():
  236. locale.setlocale(locale.LC_ALL, 'C')
  237. for name, cls in offlineimap.ui.UI_LIST.items():
  238. offlineimap.ui.UI_LIST[name] = add_notifications(cls)
  239. try:
  240. offlineimap.OfflineImap().run()
  241. except SystemExit:
  242. if '-h' in sys.argv or '--help' in sys.argv:
  243. print('\n')
  244. print_help()
  245. raise
  246. if __name__ == '__main__':
  247. main()