notifications.py 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. # -*- coding: utf-8 -*-
  2. import datetime
  3. import typing
  4. from email.header import Header
  5. from email.mime.multipart import MIMEMultipart
  6. from email.mime.text import MIMEText
  7. from email.utils import formataddr
  8. from lxml.html.diff import htmldiff
  9. from mako.template import Template
  10. from tracim.lib.base import logger
  11. from tracim.lib.email import EmailSender
  12. from tracim.lib.email import SmtpConfiguration
  13. from tracim.lib.email import send_email_through
  14. from tracim.lib.user import UserApi
  15. from tracim.lib.utils import lazy_ugettext as l_
  16. from tracim.lib.workspace import WorkspaceApi
  17. from tracim.model.auth import User
  18. from tracim.model.data import ActionDescription
  19. from tracim.model.data import Content
  20. from tracim.model.data import ContentType
  21. from tracim.model.data import UserRoleInWorkspace
  22. from tracim.model.serializers import CTX
  23. from tracim.model.serializers import Context
  24. from tracim.model.serializers import DictLikeClass
  25. class INotifier(object):
  26. """
  27. Interface for Notifier instances
  28. """
  29. def __init__(self, current_user: User=None):
  30. pass
  31. def notify_content_update(self, content: Content):
  32. raise NotImplementedError
  33. class NotifierFactory(object):
  34. @classmethod
  35. def create(cls, current_user: User=None) -> INotifier:
  36. # TODO: Find a way to import properly without cyclic import
  37. from tracim.config.app_cfg import CFG
  38. cfg = CFG.get_instance()
  39. if not cfg.EMAIL_NOTIFICATION_ACTIVATED:
  40. return DummyNotifier(current_user)
  41. return RealNotifier(current_user)
  42. class DummyNotifier(INotifier):
  43. send_count = 0
  44. def __init__(self, current_user: User=None):
  45. logger.info(self, 'Instantiating Dummy Notifier')
  46. def notify_content_update(self, content: Content):
  47. type(self).send_count += 1
  48. logger.info(self, 'Fake notifier, do not send email-notification for update of content {}'.format(content.content_id))
  49. class RealNotifier(object):
  50. def __init__(self, current_user: User=None):
  51. """
  52. :param current_user: the user that has triggered the notification
  53. :return:
  54. """
  55. logger.info(self, 'Instantiating Real Notifier')
  56. # TODO: Find a way to import properly without cyclic import
  57. from tracim.config.app_cfg import CFG
  58. cfg = CFG.get_instance()
  59. self._user = current_user
  60. self._smtp_config = SmtpConfiguration(cfg.EMAIL_NOTIFICATION_SMTP_SERVER,
  61. cfg.EMAIL_NOTIFICATION_SMTP_PORT,
  62. cfg.EMAIL_NOTIFICATION_SMTP_USER,
  63. cfg.EMAIL_NOTIFICATION_SMTP_PASSWORD)
  64. def notify_content_update(self, content: Content):
  65. # TODO: Find a way to import properly without cyclic import
  66. from tracim.config.app_cfg import CFG
  67. global_config = CFG.get_instance()
  68. if content.get_last_action().id not \
  69. in global_config.EMAIL_NOTIFICATION_NOTIFIED_EVENTS:
  70. logger.info(
  71. self,
  72. 'Skip email notification for update of content {}'
  73. 'by user {} (the action is {})'.format(
  74. content.content_id,
  75. # below: 0 means "no user"
  76. self._user.user_id if self._user else 0,
  77. content.get_last_action().id
  78. )
  79. )
  80. return
  81. logger.info(self,
  82. 'About to email-notify update'
  83. 'of content {} by user {}'.format(
  84. content.content_id,
  85. # Below: 0 means "no user"
  86. self._user.user_id if self._user else 0
  87. )
  88. )
  89. if content.type not \
  90. in global_config.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS:
  91. logger.info(
  92. self,
  93. 'Skip email notification for update of content {}'
  94. 'by user {} (the content type is {})'.format(
  95. content.type,
  96. # below: 0 means "no user"
  97. self._user.user_id if self._user else 0,
  98. content.get_last_action().id
  99. )
  100. )
  101. return
  102. logger.info(self,
  103. 'About to email-notify update'
  104. 'of content {} by user {}'.format(
  105. content.content_id,
  106. # Below: 0 means "no user"
  107. self._user.user_id if self._user else 0
  108. )
  109. )
  110. ####
  111. #
  112. # INFO - D.A. - 2014-11-05 - Emails are sent through asynchronous jobs.
  113. # For that reason, we do not give SQLAlchemy objects but ids only
  114. # (SQLA objects are related to a given thread/session)
  115. #
  116. try:
  117. if global_config.EMAIL_NOTIFICATION_PROCESSING_MODE.lower()==global_config.CST.ASYNC.lower():
  118. logger.info(self, 'Sending email in ASYNC mode')
  119. # TODO - D.A - 2014-11-06
  120. # This feature must be implemented in order to be able to scale to large communities
  121. raise NotImplementedError('Sending emails through ASYNC mode is not working yet')
  122. else:
  123. logger.info(self, 'Sending email in SYNC mode')
  124. EmailNotifier(self._smtp_config, global_config).notify_content_update(self._user.user_id, content.content_id)
  125. except Exception as e:
  126. logger.error(self, 'Exception catched during email notification: {}'.format(e.__str__()))
  127. class EST(object):
  128. """
  129. EST = Email Subject Tags - this is a convenient class - no business logic here
  130. This class is intended to agregate all dynamic content that may be included in email subjects
  131. """
  132. WEBSITE_TITLE = '{website_title}'
  133. WORKSPACE_LABEL = '{workspace_label}'
  134. CONTENT_LABEL = '{content_label}'
  135. CONTENT_STATUS_LABEL = '{content_status_label}'
  136. @classmethod
  137. def all(cls):
  138. return [
  139. cls.CONTENT_LABEL,
  140. cls.CONTENT_STATUS_LABEL,
  141. cls.WEBSITE_TITLE,
  142. cls.WORKSPACE_LABEL
  143. ]
  144. class EmailNotifier(object):
  145. """
  146. Compared to Notifier, this class is independant from the HTTP request thread
  147. TODO: Do this class really independant (but it means to get as parameter the user language
  148. and other stuff related to the turbogears environment)
  149. """
  150. def __init__(self, smtp_config: SmtpConfiguration, global_config):
  151. self._smtp_config = smtp_config
  152. self._global_config = global_config
  153. def _get_sender(self, user: User=None) -> str:
  154. """
  155. Return sender string like "Bob Dylan
  156. (via Tracim) <notification@mail.com>"
  157. :param user: user to extract display name
  158. :return: sender string
  159. """
  160. email_template = self._global_config.EMAIL_NOTIFICATION_FROM_EMAIL
  161. mail_sender_name = self._global_config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL # nopep8
  162. if user:
  163. mail_sender_name = '{name} via Tracim'.format(name=user.display_name)
  164. email_address = email_template.replace('{user_id}', str(user.user_id))
  165. # INFO - D.A. - 2017-08-04
  166. # We use email_template.replace() instead of .format() because this
  167. # method is more robust to errors in config file.
  168. #
  169. # For example, if the email is info+{userid}@tracim.fr
  170. # email.format(user_id='bob') will raise an exception
  171. # email.replace('{user_id}', 'bob') will just ignore {userid}
  172. else:
  173. email_address = email_template.replace('{user_id}', '0')
  174. return formataddr((mail_sender_name, email_address))
  175. @staticmethod
  176. def log_notification(
  177. action: str,
  178. recipient: typing.Optional[str],
  179. subject: typing.Optional[str],
  180. ) -> None:
  181. """Log notification metadata."""
  182. from tracim.config.app_cfg import CFG
  183. log_path = CFG.get_instance().EMAIL_NOTIFICATION_LOG_FILE_PATH
  184. if log_path:
  185. # TODO - A.P - 2017-09-06 - file logging inefficiency
  186. # Updating a document with 100 users to notify will leads to open
  187. # and close the file 100 times.
  188. with open(log_path, 'a') as log_file:
  189. print(
  190. datetime.datetime.now(),
  191. action,
  192. recipient,
  193. subject,
  194. sep='|',
  195. file=log_file,
  196. )
  197. def notify_content_update(self, event_actor_id: int, event_content_id: int):
  198. """
  199. Look for all users to be notified about the new content and send them an individual email
  200. :param event_actor_id: id of the user that has triggered the event
  201. :param event_content_id: related content_id
  202. :return:
  203. """
  204. # FIXME - D.A. - 2014-11-05
  205. # Dirty import. It's here in order to avoid circular import
  206. from tracim.lib.content import ContentApi
  207. user = UserApi(None).get_one(event_actor_id)
  208. logger.debug(self, 'Content: {}'.format(event_content_id))
  209. content = ContentApi(user, show_archived=True, show_deleted=True).get_one(event_content_id, ContentType.Any) # TODO - use a system user instead of the user that has triggered the event
  210. main_content = content.parent if content.type==ContentType.Comment else content
  211. notifiable_roles = WorkspaceApi(user).get_notifiable_roles(content.workspace)
  212. if len(notifiable_roles)<=0:
  213. logger.info(self, 'Skipping notification as nobody subscribed to in workspace {}'.format(content.workspace.label))
  214. return
  215. logger.info(self, 'Sending asynchronous emails to {} user(s)'.format(len(notifiable_roles)))
  216. # INFO - D.A. - 2014-11-06
  217. # The following email sender will send emails in the async task queue
  218. # This allow to build all mails through current thread but really send them (including SMTP connection)
  219. # In the other thread.
  220. #
  221. # This way, the webserver will return sooner (actually before notification emails are sent
  222. async_email_sender = EmailSender(self._smtp_config, self._global_config.EMAIL_NOTIFICATION_ACTIVATED)
  223. for role in notifiable_roles:
  224. logger.info(self, 'Sending email to {}'.format(role.user.email))
  225. to_addr = formataddr((role.user.display_name, role.user.email))
  226. #
  227. # INFO - G.M - 2017-11-15 - set content_id in header to permit reply
  228. # references can have multiple values, but only one in this case.
  229. replyto_addr = self._global_config.EMAIL_NOTIFICATION_REPLY_TO_EMAIL.replace( # nopep8
  230. '{content_id}',str(content.content_id)
  231. )
  232. reference_addr = self._global_config.EMAIL_NOTIFICATION_REFERENCES_EMAIL.replace( #nopep8
  233. '{content_id}',str(content.content_id)
  234. )
  235. #
  236. # INFO - D.A. - 2014-11-06
  237. # We do not use .format() here because the subject defined in the .ini file
  238. # may not include all required labels. In order to avoid partial format() (which result in an exception)
  239. # we do use replace and force the use of .__str__() in order to process LazyString objects
  240. #
  241. subject = self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
  242. subject = subject.replace(EST.WEBSITE_TITLE, self._global_config.WEBSITE_TITLE.__str__())
  243. subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
  244. subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
  245. subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
  246. reply_to_label = l_('{username} & all members of {workspace}').format(
  247. username=user.display_name,
  248. workspace=main_content.workspace.label)
  249. message = MIMEMultipart('alternative')
  250. message['Subject'] = subject
  251. message['From'] = self._get_sender(user)
  252. message['To'] = to_addr
  253. message['Reply-to'] = formataddr((reply_to_label, replyto_addr))
  254. # INFO - G.M - 2017-11-15
  255. # References can theorically have label, but in pratice, references
  256. # contains only message_id from parents post in thread.
  257. # To link this email to a content we create a virtual parent
  258. # in reference who contain the content_id.
  259. message['References'] = formataddr(('',reference_addr))
  260. body_text = self._build_email_body(self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
  261. body_html = self._build_email_body(self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML, role, content, user)
  262. part1 = MIMEText(body_text, 'plain', 'utf-8')
  263. part2 = MIMEText(body_html, 'html', 'utf-8')
  264. # Attach parts into message container.
  265. # According to RFC 2046, the last part of a multipart message, in this case
  266. # the HTML message, is best and preferred.
  267. message.attach(part1)
  268. message.attach(part2)
  269. self.log_notification(
  270. action='CREATED',
  271. recipient=message['To'],
  272. subject=message['Subject'],
  273. )
  274. send_email_through(async_email_sender.send_mail, message)
  275. def _build_email_body(self, mako_template_filepath: str, role: UserRoleInWorkspace, content: Content, actor: User) -> str:
  276. """
  277. Build an email body and return it as a string
  278. :param mako_template_filepath: the absolute path to the mako template to be used for email body building
  279. :param role: the role related to user to whom the email must be sent. The role is required (and not the user only) in order to show in the mail why the user receive the notification
  280. :param content: the content item related to the notification
  281. :param actor: the user at the origin of the action / notification (for example the one who wrote a comment
  282. :param config: the global configuration
  283. :return: the built email body as string. In case of multipart email, this method must be called one time for text and one time for html
  284. """
  285. logger.debug(self, 'Building email content from MAKO template {}'.format(mako_template_filepath))
  286. template = Template(filename=mako_template_filepath)
  287. # TODO - D.A. - 2014-11-06 - move this
  288. # Import is here for circular import problem
  289. import tracim.lib.helpers as helpers
  290. dictified_item = Context(CTX.EMAIL_NOTIFICATION, self._global_config.WEBSITE_BASE_URL).toDict(content)
  291. dictified_actor = Context(CTX.DEFAULT).toDict(actor)
  292. main_title = dictified_item.label
  293. content_intro = ''
  294. content_text = ''
  295. call_to_action_text = ''
  296. action = content.get_last_action().id
  297. if ActionDescription.COMMENT == action:
  298. content_intro = l_('<span id="content-intro-username">{}</span> added a comment:').format(actor.display_name)
  299. content_text = content.description
  300. call_to_action_text = l_('Answer')
  301. elif ActionDescription.CREATION == action:
  302. # Default values (if not overriden)
  303. content_text = content.description
  304. call_to_action_text = l_('View online')
  305. if ContentType.Thread == content.type:
  306. call_to_action_text = l_('Answer')
  307. content_intro = l_('<span id="content-intro-username">{}</span> started a thread entitled:').format(actor.display_name)
  308. content_text = '<p id="content-body-intro">{}</p>'.format(content.label) + \
  309. content.get_last_comment_from(actor).description
  310. elif ContentType.File == content.type:
  311. content_intro = l_('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name)
  312. if content.description:
  313. content_text = content.description
  314. else:
  315. content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
  316. elif ContentType.Page == content.type:
  317. content_intro = l_('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name)
  318. content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
  319. elif ActionDescription.REVISION == action:
  320. content_text = content.description
  321. call_to_action_text = l_('View online')
  322. if ContentType.File == content.type:
  323. content_intro = l_('<span id="content-intro-username">{}</span> uploaded a new revision.').format(actor.display_name)
  324. content_text = ''
  325. elif ContentType.Page == content.type:
  326. content_intro = l_('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name)
  327. previous_revision = content.get_previous_revision()
  328. title_diff = ''
  329. if previous_revision.label != content.label:
  330. title_diff = htmldiff(previous_revision.label, content.label)
  331. content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
  332. title_diff + \
  333. htmldiff(previous_revision.description, content.description)
  334. elif ContentType.Thread == content.type:
  335. content_intro = l_('<span id="content-intro-username">{}</span> updated the thread description.').format(actor.display_name)
  336. previous_revision = content.get_previous_revision()
  337. title_diff = ''
  338. if previous_revision.label != content.label:
  339. title_diff = htmldiff(previous_revision.label, content.label)
  340. content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
  341. title_diff + \
  342. htmldiff(previous_revision.description, content.description)
  343. # elif ContentType.Thread == content.type:
  344. # content_intro = l_('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name)
  345. # previous_revision = content.get_previous_revision()
  346. # content_text = l_('<p id="content-body-intro">Here is an overview of the changes:</p>')+ \
  347. # htmldiff(previous_revision.description, content.description)
  348. elif ActionDescription.EDITION == action:
  349. call_to_action_text = l_('View online')
  350. if ContentType.File == content.type:
  351. content_intro = l_('<span id="content-intro-username">{}</span> updated the file description.').format(actor.display_name)
  352. content_text = '<p id="content-body-intro">{}</p>'.format(content.get_label()) + \
  353. content.description
  354. elif ActionDescription.STATUS_UPDATE == action:
  355. call_to_action_text = l_('View online')
  356. intro_user_msg = l_(
  357. '<span id="content-intro-username">{}</span> '
  358. 'updated the following status:'
  359. )
  360. content_intro = intro_user_msg.format(actor.display_name)
  361. intro_body_msg = '<p id="content-body-intro">{}: {}</p>'
  362. content_text = intro_body_msg.format(
  363. content.get_label(),
  364. content.get_status().label,
  365. )
  366. if '' == content_intro and content_text == '':
  367. # Skip notification, but it's not normal
  368. logger.error(
  369. self, 'A notification is being sent but no content. '
  370. 'Here are some debug informations: [content_id: {cid}]'
  371. '[action: {act}][author: {actor}]'.format(
  372. cid=content.content_id, act=action, actor=actor
  373. )
  374. )
  375. raise ValueError('Unexpected empty notification')
  376. # Import done here because cyclic import
  377. from tracim.config.app_cfg import CFG
  378. body_content = template.render(
  379. base_url=self._global_config.WEBSITE_BASE_URL,
  380. _=l_,
  381. h=helpers,
  382. user_display_name=role.user.display_name,
  383. user_role_label=role.role_as_label(),
  384. workspace_label=role.workspace.label,
  385. content_intro=content_intro,
  386. content_text=content_text,
  387. main_title=main_title,
  388. call_to_action_text=call_to_action_text,
  389. result = DictLikeClass(item=dictified_item, actor=dictified_actor),
  390. CFG=CFG.get_instance(),
  391. )
  392. return body_content