notifier.py 24KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. # -*- coding: utf-8 -*-
  2. import datetime
  3. import typing
  4. from email.mime.multipart import MIMEMultipart
  5. from email.mime.text import MIMEText
  6. from email.utils import formataddr
  7. from lxml.html.diff import htmldiff
  8. from mako.template import Template
  9. from sqlalchemy.orm import Session
  10. from tracim_backend.config import CFG
  11. from tracim_backend.lib.core.notifications import INotifier
  12. from tracim_backend.lib.mail_notifier.sender import EmailSender
  13. from tracim_backend.lib.mail_notifier.utils import SmtpConfiguration, EST
  14. from tracim_backend.lib.mail_notifier.sender import send_email_through
  15. from tracim_backend.lib.core.workspace import WorkspaceApi
  16. from tracim_backend.lib.utils.logger import logger
  17. from tracim_backend.lib.utils.utils import get_login_frontend_url
  18. from tracim_backend.lib.utils.utils import get_email_logo_frontend_url
  19. from tracim_backend.models.auth import User
  20. from tracim_backend.app_models.contents import CONTENT_TYPES
  21. from tracim_backend.models.context_models import ContentInContext
  22. from tracim_backend.models.context_models import WorkspaceInContext
  23. from tracim_backend.models.data import ActionDescription
  24. from tracim_backend.models.data import Content
  25. from tracim_backend.models.data import UserRoleInWorkspace
  26. from tracim_backend.lib.utils.translation import fake_translator as l_, \
  27. fake_translator as _
  28. class EmailNotifier(INotifier):
  29. """
  30. EmailNotifier, this class will decide how to notify by mail
  31. in order to let a EmailManager create email
  32. """
  33. def __init__(
  34. self,
  35. config: CFG,
  36. session: Session,
  37. current_user: User=None
  38. ):
  39. """
  40. :param current_user: the user that has triggered the notification
  41. :return:
  42. """
  43. INotifier.__init__(self, config, session, current_user)
  44. logger.info(self, 'Instantiating Email Notifier')
  45. self._user = current_user
  46. self.session = session
  47. self.config = config
  48. self._smtp_config = SmtpConfiguration(
  49. self.config.EMAIL_NOTIFICATION_SMTP_SERVER,
  50. self.config.EMAIL_NOTIFICATION_SMTP_PORT,
  51. self.config.EMAIL_NOTIFICATION_SMTP_USER,
  52. self.config.EMAIL_NOTIFICATION_SMTP_PASSWORD
  53. )
  54. def notify_content_update(self, content: Content):
  55. if content.get_last_action().id not \
  56. in self.config.EMAIL_NOTIFICATION_NOTIFIED_EVENTS:
  57. logger.info(
  58. self,
  59. 'Skip email notification for update of content {}'
  60. 'by user {} (the action is {})'.format(
  61. content.content_id,
  62. # below: 0 means "no user"
  63. self._user.user_id if self._user else 0,
  64. content.get_last_action().id
  65. )
  66. )
  67. return
  68. logger.info(self,
  69. 'About to email-notify update'
  70. 'of content {} by user {}'.format(
  71. content.content_id,
  72. # Below: 0 means "no user"
  73. self._user.user_id if self._user else 0
  74. )
  75. )
  76. if content.type not \
  77. in self.config.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS:
  78. logger.info(
  79. self,
  80. 'Skip email notification for update of content {}'
  81. 'by user {} (the content type is {})'.format(
  82. content.type,
  83. # below: 0 means "no user"
  84. self._user.user_id if self._user else 0,
  85. content.get_last_action().id
  86. )
  87. )
  88. return
  89. logger.info(self,
  90. 'About to email-notify update'
  91. 'of content {} by user {}'.format(
  92. content.content_id,
  93. # Below: 0 means "no user"
  94. self._user.user_id if self._user else 0
  95. )
  96. )
  97. ####
  98. #
  99. # INFO - D.A. - 2014-11-05 - Emails are sent through asynchronous jobs.
  100. # For that reason, we do not give SQLAlchemy objects but ids only
  101. # (SQLA objects are related to a given thread/session)
  102. #
  103. try:
  104. if self.config.EMAIL_NOTIFICATION_PROCESSING_MODE.lower() == self.config.CST.ASYNC.lower():
  105. logger.info(self, 'Sending email in ASYNC mode')
  106. # TODO - D.A - 2014-11-06
  107. # This feature must be implemented in order to be able to scale to large communities
  108. raise NotImplementedError('Sending emails through ASYNC mode is not working yet')
  109. else:
  110. logger.info(self, 'Sending email in SYNC mode')
  111. EmailManager(
  112. self._smtp_config,
  113. self.config,
  114. self.session,
  115. ).notify_content_update(self._user.user_id, content.content_id)
  116. except TypeError as e:
  117. logger.error(self, 'Exception catched during email notification: {}'.format(e.__str__()))
  118. class EmailManager(object):
  119. """
  120. Compared to Notifier, this class is independant from the HTTP request thread
  121. This class will build Email and send it for both created account and content
  122. update
  123. """
  124. def __init__(
  125. self,
  126. smtp_config: SmtpConfiguration,
  127. config: CFG,
  128. session: Session
  129. ) -> None:
  130. self._smtp_config = smtp_config
  131. self.config = config
  132. self.session = session
  133. # FIXME - G.M - We need to have a session for the emailNotifier
  134. # if not self.session:
  135. # engine = get_engine(settings)
  136. # session_factory = get_session_factory(engine)
  137. # app_config = CFG(settings)
  138. def _get_sender(self, user: User=None) -> str:
  139. """
  140. Return sender string like "Bob Dylan
  141. (via Tracim) <notification@mail.com>"
  142. :param user: user to extract display name
  143. :return: sender string
  144. """
  145. email_template = self.config.EMAIL_NOTIFICATION_FROM_EMAIL
  146. mail_sender_name = self.config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL # nopep8
  147. if user:
  148. mail_sender_name = '{name} via Tracim'.format(name=user.display_name)
  149. email_address = email_template.replace('{user_id}', str(user.user_id))
  150. # INFO - D.A. - 2017-08-04
  151. # We use email_template.replace() instead of .format() because this
  152. # method is more robust to errors in config file.
  153. #
  154. # For example, if the email is info+{userid}@tracim.fr
  155. # email.format(user_id='bob') will raise an exception
  156. # email.replace('{user_id}', 'bob') will just ignore {userid}
  157. else:
  158. email_address = email_template.replace('{user_id}', '0')
  159. return formataddr((mail_sender_name, email_address))
  160. # Content Notification
  161. @staticmethod
  162. def log_notification(
  163. config: CFG,
  164. action: str,
  165. recipient: typing.Optional[str],
  166. subject: typing.Optional[str],
  167. ) -> None:
  168. """Log notification metadata."""
  169. log_path = config.EMAIL_NOTIFICATION_LOG_FILE_PATH
  170. if log_path:
  171. # TODO - A.P - 2017-09-06 - file logging inefficiency
  172. # Updating a document with 100 users to notify will leads to open
  173. # and close the file 100 times.
  174. with open(log_path, 'a') as log_file:
  175. print(
  176. datetime.datetime.now(),
  177. action,
  178. recipient,
  179. subject,
  180. sep='|',
  181. file=log_file,
  182. )
  183. def notify_content_update(
  184. self,
  185. event_actor_id: int,
  186. event_content_id: int
  187. ) -> None:
  188. """
  189. Look for all users to be notified about the new content and send them an
  190. individual email
  191. :param event_actor_id: id of the user that has triggered the event
  192. :param event_content_id: related content_id
  193. :return:
  194. """
  195. # FIXME - D.A. - 2014-11-05
  196. # Dirty import. It's here in order to avoid circular import
  197. from tracim_backend.lib.core.content import ContentApi
  198. from tracim_backend.lib.core.user import UserApi
  199. user = UserApi(
  200. None,
  201. config=self.config,
  202. session=self.session,
  203. ).get_one(event_actor_id)
  204. logger.debug(self, 'Content: {}'.format(event_content_id))
  205. content_api = ContentApi(
  206. current_user=user,
  207. session=self.session,
  208. config=self.config,
  209. )
  210. content = ContentApi(
  211. session=self.session,
  212. current_user=user, # TODO - use a system user instead of the user that has triggered the event
  213. config=self.config,
  214. show_archived=True,
  215. show_deleted=True,
  216. ).get_one(event_content_id, CONTENT_TYPES.Any_SLUG)
  217. workspace_api = WorkspaceApi(
  218. session=self.session,
  219. current_user=user,
  220. config=self.config,
  221. )
  222. workpace_in_context = workspace_api.get_workspace_with_context(workspace_api.get_one(content.workspace_id)) # nopep8
  223. main_content = content.parent if content.type == CONTENT_TYPES.Comment.slug else content # nopep8
  224. notifiable_roles = WorkspaceApi(
  225. current_user=user,
  226. session=self.session,
  227. config=self.config,
  228. ).get_notifiable_roles(content.workspace)
  229. if len(notifiable_roles) <= 0:
  230. logger.info(self, 'Skipping notification as nobody subscribed to in workspace {}'.format(content.workspace.label))
  231. return
  232. logger.info(self, 'Sending asynchronous emails to {} user(s)'.format(len(notifiable_roles)))
  233. # INFO - D.A. - 2014-11-06
  234. # The following email sender will send emails in the async task queue
  235. # This allow to build all mails through current thread but really send them (including SMTP connection)
  236. # In the other thread.
  237. #
  238. # This way, the webserver will return sooner (actually before notification emails are sent
  239. async_email_sender = EmailSender(
  240. self.config,
  241. self._smtp_config,
  242. self.config.EMAIL_NOTIFICATION_ACTIVATED
  243. )
  244. for role in notifiable_roles:
  245. logger.info(self, 'Sending email to {}'.format(role.user.email))
  246. to_addr = formataddr((role.user.display_name, role.user.email))
  247. #
  248. # INFO - G.M - 2017-11-15 - set content_id in header to permit reply
  249. # references can have multiple values, but only one in this case.
  250. replyto_addr = self.config.EMAIL_NOTIFICATION_REPLY_TO_EMAIL.replace( # nopep8
  251. '{content_id}', str(content.content_id)
  252. )
  253. reference_addr = self.config.EMAIL_NOTIFICATION_REFERENCES_EMAIL.replace( #nopep8
  254. '{content_id}',str(content.content_id)
  255. )
  256. #
  257. # INFO - D.A. - 2014-11-06
  258. # We do not use .format() here because the subject defined in the .ini file
  259. # may not include all required labels. In order to avoid partial format() (which result in an exception)
  260. # we do use replace and force the use of .__str__() in order to process LazyString objects
  261. #
  262. subject = self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
  263. subject = subject.replace(EST.WEBSITE_TITLE, self.config.WEBSITE_TITLE.__str__())
  264. subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
  265. subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
  266. subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
  267. reply_to_label = l_('{username} & all members of {workspace}').format(
  268. username=user.display_name,
  269. workspace=main_content.workspace.label)
  270. message = MIMEMultipart('alternative')
  271. message['Subject'] = subject
  272. message['From'] = self._get_sender(user)
  273. message['To'] = to_addr
  274. message['Reply-to'] = formataddr((reply_to_label, replyto_addr))
  275. # INFO - G.M - 2017-11-15
  276. # References can theorically have label, but in pratice, references
  277. # contains only message_id from parents post in thread.
  278. # To link this email to a content we create a virtual parent
  279. # in reference who contain the content_id.
  280. message['References'] = formataddr(('',reference_addr))
  281. content_in_context = content_api.get_content_in_context(content)
  282. body_text = self._build_email_body_for_content(
  283. self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT,
  284. role,
  285. content_in_context,
  286. workpace_in_context,
  287. user
  288. )
  289. body_html = self._build_email_body_for_content(
  290. self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML,
  291. role,
  292. content_in_context,
  293. workpace_in_context,
  294. user
  295. )
  296. part1 = MIMEText(body_text, 'plain', 'utf-8')
  297. part2 = MIMEText(body_html, 'html', 'utf-8')
  298. # Attach parts into message container.
  299. # According to RFC 2046, the last part of a multipart message, in this case
  300. # the HTML message, is best and preferred.
  301. message.attach(part1)
  302. message.attach(part2)
  303. self.log_notification(
  304. action='CREATED',
  305. recipient=message['To'],
  306. subject=message['Subject'],
  307. config=self.config,
  308. )
  309. send_email_through(
  310. self.config,
  311. async_email_sender.send_mail,
  312. message
  313. )
  314. def notify_created_account(
  315. self,
  316. user: User,
  317. password: str,
  318. ) -> None:
  319. """
  320. Send created account email to given user.
  321. :param password: choosed password
  322. :param user: user to notify
  323. """
  324. # TODO BS 20160712: Cyclic import
  325. logger.debug(self, 'user: {}'.format(user.user_id))
  326. logger.info(self, 'Sending asynchronous email to 1 user ({0})'.format(
  327. user.email,
  328. ))
  329. async_email_sender = EmailSender(
  330. self.config,
  331. self._smtp_config,
  332. self.config.EMAIL_NOTIFICATION_ACTIVATED
  333. )
  334. subject = \
  335. self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_SUBJECT \
  336. .replace(
  337. EST.WEBSITE_TITLE,
  338. self.config.WEBSITE_TITLE.__str__()
  339. )
  340. message = MIMEMultipart('alternative')
  341. message['Subject'] = subject
  342. message['From'] = self._get_sender()
  343. message['To'] = formataddr((user.get_display_name(), user.email))
  344. text_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT # nopep8
  345. html_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML # nopep8
  346. context = {
  347. 'user': user,
  348. 'password': password,
  349. # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for logo_url # nopep8
  350. 'logo_url': get_email_logo_frontend_url(self.config),
  351. # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for login_url # nopep8
  352. 'login_url': get_login_frontend_url(self.config),
  353. }
  354. body_text = self._render_template(
  355. mako_template_filepath=text_template_file_path,
  356. context=context
  357. )
  358. body_html = self._render_template(
  359. mako_template_filepath=html_template_file_path,
  360. context=context,
  361. )
  362. part1 = MIMEText(body_text, 'plain', 'utf-8')
  363. part2 = MIMEText(body_html, 'html', 'utf-8')
  364. # Attach parts into message container.
  365. # According to RFC 2046, the last part of a multipart message,
  366. # in this case the HTML message, is best and preferred.
  367. message.attach(part1)
  368. message.attach(part2)
  369. send_email_through(
  370. config=self.config,
  371. sendmail_callable=async_email_sender.send_mail,
  372. message=message
  373. )
  374. def _render_template(
  375. self,
  376. mako_template_filepath: str,
  377. context: dict
  378. ) -> str:
  379. """
  380. Render mako template with all needed current variables.
  381. :param mako_template_filepath: file path of mako template
  382. :param context: dict with template context
  383. :return: template rendered string
  384. """
  385. template = Template(filename=mako_template_filepath)
  386. return template.render(
  387. _=_,
  388. config=self.config,
  389. **context
  390. )
  391. def _build_email_body_for_content(
  392. self,
  393. mako_template_filepath: str,
  394. role: UserRoleInWorkspace,
  395. content_in_context: ContentInContext,
  396. workspace_in_context: WorkspaceInContext,
  397. actor: User,
  398. ) -> str:
  399. """
  400. Build an email body and return it as a string
  401. :param mako_template_filepath: the absolute path to the mako template to be used for email body building
  402. :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
  403. :param content: the content item related to the notification
  404. :param actor: the user at the origin of the action / notification (for example the one who wrote a comment
  405. :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
  406. """
  407. logger.debug(self, 'Building email content from MAKO template {}'.format(mako_template_filepath))
  408. content = content_in_context.content
  409. main_title = content.label
  410. content_intro = ''
  411. content_text = ''
  412. call_to_action_text = ''
  413. call_to_action_url = content_in_context.frontend_url
  414. # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for status_icon_url # nopep8
  415. status_icon_url = ''
  416. workspace_url = workspace_in_context.frontend_url
  417. # TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for logo_url # nopep8
  418. logo_url = get_email_logo_frontend_url(self.config)
  419. action = content.get_last_action().id
  420. if ActionDescription.COMMENT == action:
  421. content_intro = l_('<span id="content-intro-username">{}</span> added a comment:').format(actor.display_name)
  422. content_text = content.description
  423. call_to_action_text = l_('Answer')
  424. elif ActionDescription.CREATION == action:
  425. # Default values (if not overriden)
  426. content_text = content.description
  427. call_to_action_text = l_('View online')
  428. if CONTENT_TYPES.Thread.slug == content.type:
  429. call_to_action_text = l_('Answer')
  430. content_intro = l_('<span id="content-intro-username">{}</span> started a thread entitled:').format(actor.display_name)
  431. content_text = '<p id="content-body-intro">{}</p>'.format(content.label) + \
  432. content.get_last_comment_from(actor).description
  433. elif CONTENT_TYPES.File.slug == content.type:
  434. content_intro = l_('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name)
  435. if content.description:
  436. content_text = content.description
  437. else:
  438. content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
  439. elif CONTENT_TYPES.Page.slug == content.type:
  440. content_intro = l_('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name)
  441. content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
  442. elif ActionDescription.REVISION == action:
  443. content_text = content.description
  444. call_to_action_text = l_('View online')
  445. if CONTENT_TYPES.File.slug == content.type:
  446. content_intro = l_('<span id="content-intro-username">{}</span> uploaded a new revision.').format(actor.display_name)
  447. content_text = ''
  448. elif CONTENT_TYPES.Page.slug == content.type:
  449. content_intro = l_('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name)
  450. previous_revision = content.get_previous_revision()
  451. title_diff = ''
  452. if previous_revision.label != content.label:
  453. title_diff = htmldiff(previous_revision.label, content.label)
  454. content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
  455. title_diff + \
  456. htmldiff(previous_revision.description, content.description)
  457. elif CONTENT_TYPES.Thread.slug == content.type:
  458. content_intro = l_('<span id="content-intro-username">{}</span> updated the thread description.').format(actor.display_name)
  459. previous_revision = content.get_previous_revision()
  460. title_diff = ''
  461. if previous_revision.label != content.label:
  462. title_diff = htmldiff(previous_revision.label, content.label)
  463. content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
  464. title_diff + \
  465. htmldiff(previous_revision.description, content.description)
  466. elif ActionDescription.EDITION == action:
  467. call_to_action_text = l_('View online')
  468. if CONTENT_TYPES.File.slug == content.type:
  469. content_intro = l_('<span id="content-intro-username">{}</span> updated the file description.').format(actor.display_name)
  470. content_text = '<p id="content-body-intro">{}</p>'.format(content.get_label()) + \
  471. content.description
  472. elif ActionDescription.STATUS_UPDATE == action:
  473. call_to_action_text = l_('View online')
  474. intro_user_msg = l_(
  475. '<span id="content-intro-username">{}</span> '
  476. 'updated the following status:'
  477. )
  478. content_intro = intro_user_msg.format(actor.display_name)
  479. intro_body_msg = '<p id="content-body-intro">{}: {}</p>'
  480. content_text = intro_body_msg.format(
  481. content.get_label(),
  482. content.get_status().label,
  483. )
  484. if '' == content_intro and content_text == '':
  485. # Skip notification, but it's not normal
  486. logger.error(
  487. self, 'A notification is being sent but no content. '
  488. 'Here are some debug informations: [content_id: {cid}]'
  489. '[action: {act}][author: {actor}]'.format(
  490. cid=content.content_id, act=action, actor=actor
  491. )
  492. )
  493. raise ValueError('Unexpected empty notification')
  494. context = {
  495. 'user': role.user,
  496. 'workspace': role.workspace,
  497. 'workspace_url': workspace_url,
  498. 'main_title': main_title,
  499. 'status_label': content.get_status().label,
  500. 'status_icon_url': status_icon_url,
  501. 'role_label': role.role_as_label(),
  502. 'content_intro': content_intro,
  503. 'content_text': content_text,
  504. 'call_to_action_text': call_to_action_text,
  505. 'call_to_action_url': call_to_action_url,
  506. 'logo_url': logo_url,
  507. }
  508. user = role.user
  509. workspace = role.workspace
  510. body_content = self._render_template(
  511. mako_template_filepath=mako_template_filepath,
  512. context=context,
  513. )
  514. return body_content
  515. def get_email_manager(config: CFG, session: Session):
  516. """
  517. :return: EmailManager instance
  518. """
  519. #  TODO: Find a way to import properly without cyclic import
  520. smtp_config = SmtpConfiguration(
  521. config.EMAIL_NOTIFICATION_SMTP_SERVER,
  522. config.EMAIL_NOTIFICATION_SMTP_PORT,
  523. config.EMAIL_NOTIFICATION_SMTP_USER,
  524. config.EMAIL_NOTIFICATION_SMTP_PASSWORD
  525. )
  526. return EmailManager(config=config, smtp_config=smtp_config, session=session)