123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572 |
-
- import datetime
- import typing
-
- from email.mime.multipart import MIMEMultipart
- from email.mime.text import MIMEText
- from email.utils import formataddr
-
- from lxml.html.diff import htmldiff
- from mako.template import Template
- from sqlalchemy.orm import Session
-
- from tracim_backend import CFG
- from tracim_backend.lib.core.notifications import INotifier
- from tracim_backend.lib.mail_notifier.sender import EmailSender
- from tracim_backend.lib.mail_notifier.utils import SmtpConfiguration, EST
- from tracim_backend.lib.mail_notifier.sender import send_email_through
- from tracim_backend.lib.core.workspace import WorkspaceApi
- from tracim_backend.lib.utils.logger import logger
- from tracim_backend.models import User
- from tracim_backend.models.auth import User
- from tracim_backend.models.data import ActionDescription
- from tracim_backend.models.data import Content
- from tracim_backend.models.data import ContentType
- from tracim_backend.models.data import UserRoleInWorkspace
- from tracim_backend.lib.utils.translation import fake_translator as l_, \
- fake_translator as _
-
-
- class EmailNotifier(INotifier):
- """
- EmailNotifier, this class will decide how to notify by mail
- in order to let a EmailManager create email
- """
-
- def __init__(
- self,
- config: CFG,
- session: Session,
- current_user: User=None
- ):
- """
- :param current_user: the user that has triggered the notification
- :return:
- """
- INotifier.__init__(self, config, session, current_user)
- logger.info(self, 'Instantiating Email Notifier')
-
- self._user = current_user
- self.session = session
- self.config = config
- self._smtp_config = SmtpConfiguration(
- self.config.EMAIL_NOTIFICATION_SMTP_SERVER,
- self.config.EMAIL_NOTIFICATION_SMTP_PORT,
- self.config.EMAIL_NOTIFICATION_SMTP_USER,
- self.config.EMAIL_NOTIFICATION_SMTP_PASSWORD
- )
-
- def notify_content_update(self, content: Content):
-
- if content.get_last_action().id not \
- in self.config.EMAIL_NOTIFICATION_NOTIFIED_EVENTS:
- logger.info(
- self,
- 'Skip email notification for update of content {}'
- 'by user {} (the action is {})'.format(
- content.content_id,
-
- self._user.user_id if self._user else 0,
- content.get_last_action().id
- )
- )
- return
-
- logger.info(self,
- 'About to email-notify update'
- 'of content {} by user {}'.format(
- content.content_id,
-
- self._user.user_id if self._user else 0
- )
- )
-
- if content.type not \
- in self.config.EMAIL_NOTIFICATION_NOTIFIED_CONTENTS:
- logger.info(
- self,
- 'Skip email notification for update of content {}'
- 'by user {} (the content type is {})'.format(
- content.type,
-
- self._user.user_id if self._user else 0,
- content.get_last_action().id
- )
- )
- return
-
- logger.info(self,
- 'About to email-notify update'
- 'of content {} by user {}'.format(
- content.content_id,
-
- self._user.user_id if self._user else 0
- )
- )
-
-
-
-
-
-
-
- try:
- if self.config.EMAIL_NOTIFICATION_PROCESSING_MODE.lower() == self.config.CST.ASYNC.lower():
- logger.info(self, 'Sending email in ASYNC mode')
-
-
- raise NotImplementedError('Sending emails through ASYNC mode is not working yet')
- else:
- logger.info(self, 'Sending email in SYNC mode')
- EmailManager(
- self._smtp_config,
- self.config,
- self.session,
- ).notify_content_update(self._user.user_id, content.content_id)
- except TypeError as e:
- logger.error(self, 'Exception catched during email notification: {}'.format(e.__str__()))
-
-
- class EmailManager(object):
- """
- Compared to Notifier, this class is independant from the HTTP request thread
- This class will build Email and send it for both created account and content
- update
- """
-
- def __init__(
- self,
- smtp_config: SmtpConfiguration,
- config: CFG,
- session: Session
- ) -> None:
- self._smtp_config = smtp_config
- self.config = config
- self.session = session
-
-
-
-
-
-
-
- def _get_sender(self, user: User=None) -> str:
- """
- Return sender string like "Bob Dylan
- (via Tracim) <notification@mail.com>"
- :param user: user to extract display name
- :return: sender string
- """
-
- email_template = self.config.EMAIL_NOTIFICATION_FROM_EMAIL
- mail_sender_name = self.config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL
- if user:
- mail_sender_name = '{name} via Tracim'.format(name=user.display_name)
- email_address = email_template.replace('{user_id}', str(user.user_id))
-
-
-
-
-
-
-
- else:
- email_address = email_template.replace('{user_id}', '0')
-
- return formataddr((mail_sender_name, email_address))
-
-
-
- @staticmethod
- def log_notification(
- config: CFG,
- action: str,
- recipient: typing.Optional[str],
- subject: typing.Optional[str],
- ) -> None:
- """Log notification metadata."""
- log_path = config.EMAIL_NOTIFICATION_LOG_FILE_PATH
- if log_path:
-
-
-
- with open(log_path, 'a') as log_file:
- print(
- datetime.datetime.now(),
- action,
- recipient,
- subject,
- sep='|',
- file=log_file,
- )
-
- def notify_content_update(
- self,
- event_actor_id: int,
- event_content_id: int
- ) -> None:
- """
- Look for all users to be notified about the new content and send them an
- individual email
- :param event_actor_id: id of the user that has triggered the event
- :param event_content_id: related content_id
- :return:
- """
-
-
- from tracim_backend.lib.core.content import ContentApi
- from tracim_backend.lib.core.user import UserApi
- user = UserApi(
- None,
- config=self.config,
- session=self.session,
- ).get_one(event_actor_id)
- logger.debug(self, 'Content: {}'.format(event_content_id))
- content_api = ContentApi(
- current_user=user,
- session=self.session,
- config=self.config,
- )
- content = ContentApi(
- session=self.session,
- current_user=user,
- config=self.config,
- show_archived=True,
- show_deleted=True,
- ).get_one(event_content_id, ContentType.Any)
- main_content = content.parent if content.type == ContentType.Comment else content
- notifiable_roles = WorkspaceApi(
- current_user=user,
- session=self.session,
- config=self.config,
- ).get_notifiable_roles(content.workspace)
-
- if len(notifiable_roles) <= 0:
- logger.info(self, 'Skipping notification as nobody subscribed to in workspace {}'.format(content.workspace.label))
- return
-
-
- logger.info(self, 'Sending asynchronous emails to {} user(s)'.format(len(notifiable_roles)))
-
-
-
-
-
-
- async_email_sender = EmailSender(
- self.config,
- self._smtp_config,
- self.config.EMAIL_NOTIFICATION_ACTIVATED
- )
- for role in notifiable_roles:
- logger.info(self, 'Sending email to {}'.format(role.user.email))
- to_addr = formataddr((role.user.display_name, role.user.email))
-
-
-
- replyto_addr = self.config.EMAIL_NOTIFICATION_REPLY_TO_EMAIL.replace(
- '{content_id}',str(content.content_id)
- )
-
- reference_addr = self.config.EMAIL_NOTIFICATION_REFERENCES_EMAIL.replace(
- '{content_id}',str(content.content_id)
- )
-
-
-
-
-
-
- subject = self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT
- subject = subject.replace(EST.WEBSITE_TITLE, self.config.WEBSITE_TITLE.__str__())
- subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
- subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
- subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
- reply_to_label = l_('{username} & all members of {workspace}').format(
- username=user.display_name,
- workspace=main_content.workspace.label)
-
- message = MIMEMultipart('alternative')
- message['Subject'] = subject
- message['From'] = self._get_sender(user)
- message['To'] = to_addr
- message['Reply-to'] = formataddr((reply_to_label, replyto_addr))
-
-
-
-
-
- message['References'] = formataddr(('',reference_addr))
- body_text = self._build_email_body_for_content(self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
- body_html = self._build_email_body_for_content(self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML, role, content, user)
-
- part1 = MIMEText(body_text, 'plain', 'utf-8')
- part2 = MIMEText(body_html, 'html', 'utf-8')
-
-
-
- message.attach(part1)
- message.attach(part2)
-
- self.log_notification(
- action='CREATED',
- recipient=message['To'],
- subject=message['Subject'],
- config=self.config,
- )
-
- send_email_through(
- self.config,
- async_email_sender.send_mail,
- message
- )
-
- def notify_created_account(
- self,
- user: User,
- password: str,
- ) -> None:
- """
- Send created account email to given user.
-
- :param password: choosed password
- :param user: user to notify
- """
-
- logger.debug(self, 'user: {}'.format(user.user_id))
- logger.info(self, 'Sending asynchronous email to 1 user ({0})'.format(
- user.email,
- ))
-
- async_email_sender = EmailSender(
- self.config,
- self._smtp_config,
- self.config.EMAIL_NOTIFICATION_ACTIVATED
- )
-
- subject = \
- self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_SUBJECT \
- .replace(
- EST.WEBSITE_TITLE,
- self.config.WEBSITE_TITLE.__str__()
- )
- message = MIMEMultipart('alternative')
- message['Subject'] = subject
- message['From'] = self._get_sender()
- message['To'] = formataddr((user.get_display_name(), user.email))
-
- text_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT
- html_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML
-
- context = {
- 'user': user,
- 'password': password,
-
- 'logo_url': '',
-
- 'login_url': self.config.WEBSITE_BASE_URL,
- }
- body_text = self._render_template(
- mako_template_filepath=text_template_file_path,
- context=context
- )
-
- body_html = self._render_template(
- mako_template_filepath=html_template_file_path,
- context=context,
- )
-
- part1 = MIMEText(body_text, 'plain', 'utf-8')
- part2 = MIMEText(body_html, 'html', 'utf-8')
-
-
-
-
- message.attach(part1)
- message.attach(part2)
-
- send_email_through(
- config=self.config,
- sendmail_callable=async_email_sender.send_mail,
- message=message
- )
-
- def _render_template(
- self,
- mako_template_filepath: str,
- context: dict
- ) -> str:
- """
- Render mako template with all needed current variables.
-
- :param mako_template_filepath: file path of mako template
- :param context: dict with template context
- :return: template rendered string
- """
-
- template = Template(filename=mako_template_filepath)
- return template.render(
- _=_,
- config=self.config,
- **context
- )
-
- def _build_email_body_for_content(
- self,
- mako_template_filepath: str,
- role: UserRoleInWorkspace,
- content: Content,
- actor: User
- ) -> str:
- """
- Build an email body and return it as a string
- :param mako_template_filepath: the absolute path to the mako template to be used for email body building
- :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
- :param content: the content item related to the notification
- :param actor: the user at the origin of the action / notification (for example the one who wrote a comment
- :param config: the global configuration
- :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
- """
- logger.debug(self, 'Building email content from MAKO template {}'.format(mako_template_filepath))
-
- main_title = content.label
- content_intro = ''
- content_text = ''
- call_to_action_text = ''
-
-
- call_to_action_url =''
-
- status_icon_url = ''
-
- workspace_url = ''
-
- logo_url = ''
-
- action = content.get_last_action().id
- if ActionDescription.COMMENT == action:
- content_intro = l_('<span id="content-intro-username">{}</span> added a comment:').format(actor.display_name)
- content_text = content.description
- call_to_action_text = l_('Answer')
-
- elif ActionDescription.CREATION == action:
-
-
- content_text = content.description
- call_to_action_text = l_('View online')
-
- if ContentType.Thread == content.type:
- call_to_action_text = l_('Answer')
- content_intro = l_('<span id="content-intro-username">{}</span> started a thread entitled:').format(actor.display_name)
- content_text = '<p id="content-body-intro">{}</p>'.format(content.label) + \
- content.get_last_comment_from(actor).description
-
- elif ContentType.File == content.type:
- content_intro = l_('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name)
- if content.description:
- content_text = content.description
- else:
- content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
-
- elif ContentType.Page == content.type:
- content_intro = l_('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name)
- content_text = '<span id="content-body-only-title">{}</span>'.format(content.label)
-
- elif ActionDescription.REVISION == action:
- content_text = content.description
- call_to_action_text = l_('View online')
-
- if ContentType.File == content.type:
- content_intro = l_('<span id="content-intro-username">{}</span> uploaded a new revision.').format(actor.display_name)
- content_text = ''
-
- elif ContentType.Page == content.type:
- content_intro = l_('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name)
- previous_revision = content.get_previous_revision()
- title_diff = ''
- if previous_revision.label != content.label:
- title_diff = htmldiff(previous_revision.label, content.label)
- content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
- title_diff + \
- htmldiff(previous_revision.description, content.description)
-
- elif ContentType.Thread == content.type:
- content_intro = l_('<span id="content-intro-username">{}</span> updated the thread description.').format(actor.display_name)
- previous_revision = content.get_previous_revision()
- title_diff = ''
- if previous_revision.label != content.label:
- title_diff = htmldiff(previous_revision.label, content.label)
- content_text = str(l_('<p id="content-body-intro">Here is an overview of the changes:</p>'))+ \
- title_diff + \
- htmldiff(previous_revision.description, content.description)
-
- elif ActionDescription.EDITION == action:
- call_to_action_text = l_('View online')
-
- if ContentType.File == content.type:
- content_intro = l_('<span id="content-intro-username">{}</span> updated the file description.').format(actor.display_name)
- content_text = '<p id="content-body-intro">{}</p>'.format(content.get_label()) + \
- content.description
-
- elif ActionDescription.STATUS_UPDATE == action:
- call_to_action_text = l_('View online')
- intro_user_msg = l_(
- '<span id="content-intro-username">{}</span> '
- 'updated the following status:'
- )
- content_intro = intro_user_msg.format(actor.display_name)
- intro_body_msg = '<p id="content-body-intro">{}: {}</p>'
- content_text = intro_body_msg.format(
- content.get_label(),
- content.get_status().label,
- )
-
- if '' == content_intro and content_text == '':
-
- logger.error(
- self, 'A notification is being sent but no content. '
- 'Here are some debug informations: [content_id: {cid}]'
- '[action: {act}][author: {actor}]'.format(
- cid=content.content_id, act=action, actor=actor
- )
- )
- raise ValueError('Unexpected empty notification')
-
- context = {
- 'user': role.user,
- 'workspace': role.workspace,
- 'workspace_url': workspace_url,
- 'main_title': main_title,
- 'status_label': content.get_status().label,
- 'status_icon_url': status_icon_url,
- 'role_label': role.role_as_label(),
- 'content_intro': content_intro,
- 'content_text': content_text,
- 'call_to_action_text': call_to_action_text,
- 'call_to_action_url': call_to_action_url,
- 'logo_url': logo_url,
- }
- user = role.user
- workspace = role.workspace
- body_content = self._render_template(
- mako_template_filepath=mako_template_filepath,
- context=context,
- )
- return body_content
-
-
- def get_email_manager(config: CFG, session: Session):
- """
- :return: EmailManager instance
- """
-
-
- smtp_config = SmtpConfiguration(
- config.EMAIL_NOTIFICATION_SMTP_SERVER,
- config.EMAIL_NOTIFICATION_SMTP_PORT,
- config.EMAIL_NOTIFICATION_SMTP_USER,
- config.EMAIL_NOTIFICATION_SMTP_PASSWORD
- )
-
- return EmailManager(config=config, smtp_config=smtp_config, session=session)
|