|
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 smtplib import SMTPRecipientsRefused |
|
8
|
|
|
|
|
9
|
|
|
from lxml.html.diff import htmldiff |
|
10
|
|
|
from mako.template import Template |
|
11
|
|
|
from sqlalchemy.orm import Session |
|
12
|
|
|
|
|
13
|
|
|
from tracim_backend.app_models.contents import content_type_list |
|
14
|
|
|
from tracim_backend.config import CFG |
|
15
|
|
|
from tracim_backend.exceptions import EmptyNotificationError |
|
16
|
|
|
from tracim_backend.lib.core.notifications import INotifier |
|
17
|
|
|
from tracim_backend.lib.core.workspace import WorkspaceApi |
|
18
|
|
|
from tracim_backend.lib.mail_notifier.sender import EmailSender |
|
19
|
|
|
from tracim_backend.lib.mail_notifier.sender import send_email_through |
|
20
|
|
|
from tracim_backend.lib.mail_notifier.utils import EST |
|
21
|
|
|
from tracim_backend.lib.mail_notifier.utils import SmtpConfiguration |
|
22
|
|
|
from tracim_backend.lib.utils.logger import logger |
|
23
|
|
|
from tracim_backend.lib.utils.translation import Translator |
|
24
|
|
|
from tracim_backend.lib.utils.utils import get_email_logo_frontend_url |
|
25
|
|
|
from tracim_backend.lib.utils.utils import get_login_frontend_url |
|
26
|
|
|
from tracim_backend.lib.utils.utils import get_reset_password_frontend_url |
|
27
|
|
|
from tracim_backend.models.auth import User |
|
28
|
|
|
from tracim_backend.models.context_models import ContentInContext |
|
29
|
|
|
from tracim_backend.models.context_models import WorkspaceInContext |
|
30
|
|
|
from tracim_backend.models.data import ActionDescription |
|
31
|
|
|
from tracim_backend.models.data import Content |
|
32
|
|
|
from tracim_backend.models.data import UserRoleInWorkspace |
|
33
|
|
|
|
|
34
|
|
|
|
|
35
|
|
|
class EmailNotifier(INotifier): |
|
36
|
|
|
""" |
|
37
|
|
|
EmailNotifier, this class will decide how to notify by mail |
|
38
|
|
|
in order to let a EmailManager create email |
|
39
|
|
|
""" |
|
40
|
|
|
|
|
41
|
|
|
def __init__( |
|
42
|
|
|
self, |
|
43
|
|
|
config: CFG, |
|
44
|
|
|
session: Session, |
|
45
|
|
|
current_user: User=None |
|
46
|
|
|
): |
|
47
|
|
|
""" |
|
48
|
|
|
:param current_user: the user that has triggered the notification |
|
49
|
|
|
:return: |
|
50
|
|
|
""" |
|
51
|
|
|
INotifier.__init__(self, config, session, current_user) |
|
52
|
|
|
logger.info(self, 'Instantiating Email Notifier') |
|
53
|
|
|
|
|
54
|
|
|
self._user = current_user |
|
55
|
|
|
self.session = session |
|
56
|
|
|
self.config = config |
|
57
|
|
|
self._smtp_config = SmtpConfiguration( |
|
58
|
|
|
self.config.EMAIL_NOTIFICATION_SMTP_SERVER, |
|
59
|
|
|
self.config.EMAIL_NOTIFICATION_SMTP_PORT, |
|
60
|
|
|
self.config.EMAIL_NOTIFICATION_SMTP_USER, |
|
61
|
|
|
self.config.EMAIL_NOTIFICATION_SMTP_PASSWORD |
|
62
|
|
|
) |
|
63
|
|
|
|
|
64
|
|
|
def notify_content_update(self, content: Content): |
|
65
|
|
|
|
|
66
|
|
|
if content.get_last_action().id not \ |
|
67
|
|
|
in self.config.EMAIL_NOTIFICATION_NOTIFIED_EVENTS: |
|
68
|
|
|
logger.info( |
|
69
|
|
|
self, |
|
70
|
|
|
'Skip email notification for update of content {}' |
|
71
|
|
|
'by user {} (the action is {})'.format( |
|
72
|
|
|
content.content_id, |
|
73
|
|
|
# below: 0 means "no user" |
|
74
|
|
|
self._user.user_id if self._user else 0, |
|
75
|
|
|
content.get_last_action().id |
|
76
|
|
|
) |
|
77
|
|
|
) |
|
78
|
|
|
return |
|
79
|
|
|
|
|
80
|
|
|
logger.info(self, |
|
81
|
|
|
'About to email-notify update' |
|
82
|
|
|
'of content {} by user {}'.format( |
|
83
|
|
|
content.content_id, |
|
84
|
|
|
# Below: 0 means "no user" |
|
85
|
|
|
self._user.user_id if self._user else 0 |
|
86
|
|
|
) |
|
87
|
|
|
) |
|
88
|
|
|
|
|
89
|
|
|
if content.type not \ |
|
90
|
|
|
in self.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
|
|
|
|
|
103
|
|
|
logger.info(self, |
|
104
|
|
|
'About to email-notify update' |
|
105
|
|
|
'of content {} by user {}'.format( |
|
106
|
|
|
content.content_id, |
|
107
|
|
|
# Below: 0 means "no user" |
|
108
|
|
|
self._user.user_id if self._user else 0 |
|
109
|
|
|
) |
|
110
|
|
|
) |
|
111
|
|
|
|
|
112
|
|
|
#### |
|
113
|
|
|
# |
|
114
|
|
|
# INFO - D.A. - 2014-11-05 - Emails are sent through asynchronous jobs. |
|
115
|
|
|
# For that reason, we do not give SQLAlchemy objects but ids only |
|
116
|
|
|
# (SQLA objects are related to a given thread/session) |
|
117
|
|
|
# |
|
118
|
|
|
try: |
|
119
|
|
|
if self.config.EMAIL_NOTIFICATION_PROCESSING_MODE.lower() == self.config.CST.ASYNC.lower(): |
|
120
|
|
|
logger.info(self, 'Sending email in ASYNC mode') |
|
121
|
|
|
# TODO - D.A - 2014-11-06 |
|
122
|
|
|
# This feature must be implemented in order to be able to scale to large communities |
|
123
|
|
|
raise NotImplementedError('Sending emails through ASYNC mode is not working yet') |
|
124
|
|
|
else: |
|
125
|
|
|
logger.info(self, 'Sending email in SYNC mode') |
|
126
|
|
|
EmailManager( |
|
127
|
|
|
self._smtp_config, |
|
128
|
|
|
self.config, |
|
129
|
|
|
self.session, |
|
130
|
|
|
).notify_content_update(self._user.user_id, content.content_id) |
|
131
|
|
|
except Exception as e: |
|
132
|
|
|
# TODO - G.M - 2018-08-27 - Do Better catching for exception here |
|
133
|
|
|
logger.error(self, 'Exception catched during email notification: {}'.format(e.__str__())) |
|
134
|
|
|
logger.exception(self, e) |
|
135
|
|
|
|
|
136
|
|
|
|
|
137
|
|
|
class EmailManager(object): |
|
138
|
|
|
""" |
|
139
|
|
|
Compared to Notifier, this class is independant from the HTTP request thread |
|
140
|
|
|
This class will build Email and send it for both created account and content |
|
141
|
|
|
update |
|
142
|
|
|
""" |
|
143
|
|
|
|
|
144
|
|
|
def __init__( |
|
145
|
|
|
self, |
|
146
|
|
|
smtp_config: SmtpConfiguration, |
|
147
|
|
|
config: CFG, |
|
148
|
|
|
session: Session |
|
149
|
|
|
) -> None: |
|
150
|
|
|
self._smtp_config = smtp_config |
|
151
|
|
|
self.config = config |
|
152
|
|
|
self.session = session |
|
153
|
|
|
# FIXME - G.M - We need to have a session for the emailNotifier |
|
154
|
|
|
|
|
155
|
|
|
# if not self.session: |
|
156
|
|
|
# engine = get_engine(settings) |
|
157
|
|
|
# session_factory = get_session_factory(engine) |
|
158
|
|
|
# app_config = CFG(settings) |
|
159
|
|
|
|
|
160
|
|
|
def _get_sender(self, user: User=None) -> str: |
|
161
|
|
|
""" |
|
162
|
|
|
Return sender string like "Bob Dylan |
|
163
|
|
|
(via Tracim) <[email protected]>" |
|
164
|
|
|
:param user: user to extract display name |
|
165
|
|
|
:return: sender string |
|
166
|
|
|
""" |
|
167
|
|
|
|
|
168
|
|
|
email_template = self.config.EMAIL_NOTIFICATION_FROM_EMAIL |
|
169
|
|
|
mail_sender_name = self.config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL # nopep8 |
|
170
|
|
|
if user: |
|
171
|
|
|
mail_sender_name = '{name} via Tracim'.format(name=user.display_name) |
|
172
|
|
|
email_address = email_template.replace('{user_id}', str(user.user_id)) |
|
173
|
|
|
# INFO - D.A. - 2017-08-04 |
|
174
|
|
|
# We use email_template.replace() instead of .format() because this |
|
175
|
|
|
# method is more robust to errors in config file. |
|
176
|
|
|
# |
|
177
|
|
|
# For example, if the email is info+{userid}@tracim.fr |
|
178
|
|
|
# email.format(user_id='bob') will raise an exception |
|
179
|
|
|
# email.replace('{user_id}', 'bob') will just ignore {userid} |
|
180
|
|
|
else: |
|
181
|
|
|
email_address = email_template.replace('{user_id}', '0') |
|
182
|
|
|
|
|
183
|
|
|
return formataddr((mail_sender_name, email_address)) |
|
184
|
|
|
|
|
185
|
|
|
# Content Notification |
|
186
|
|
|
|
|
187
|
|
|
@staticmethod |
|
188
|
|
|
def log_notification( |
|
189
|
|
|
config: CFG, |
|
190
|
|
|
action: str, |
|
191
|
|
|
recipient: typing.Optional[str], |
|
192
|
|
|
subject: typing.Optional[str], |
|
193
|
|
|
) -> None: |
|
194
|
|
|
"""Log notification metadata.""" |
|
195
|
|
|
log_path = config.EMAIL_NOTIFICATION_LOG_FILE_PATH |
|
196
|
|
|
if log_path: |
|
197
|
|
|
# TODO - A.P - 2017-09-06 - file logging inefficiency |
|
198
|
|
|
# Updating a document with 100 users to notify will leads to open |
|
199
|
|
|
# and close the file 100 times. |
|
200
|
|
|
with open(log_path, 'a') as log_file: |
|
201
|
|
|
print( |
|
202
|
|
|
datetime.datetime.now(), |
|
203
|
|
|
action, |
|
204
|
|
|
recipient, |
|
205
|
|
|
subject, |
|
206
|
|
|
sep='|', |
|
207
|
|
|
file=log_file, |
|
208
|
|
|
) |
|
209
|
|
|
|
|
210
|
|
|
def notify_content_update( |
|
211
|
|
|
self, |
|
212
|
|
|
event_actor_id: int, |
|
213
|
|
|
event_content_id: int |
|
214
|
|
|
) -> None: |
|
215
|
|
|
""" |
|
216
|
|
|
Look for all users to be notified about the new content and send them an |
|
217
|
|
|
individual email |
|
218
|
|
|
:param event_actor_id: id of the user that has triggered the event |
|
219
|
|
|
:param event_content_id: related content_id |
|
220
|
|
|
:return: |
|
221
|
|
|
""" |
|
222
|
|
|
# FIXME - D.A. - 2014-11-05 |
|
223
|
|
|
# Dirty import. It's here in order to avoid circular import |
|
224
|
|
|
from tracim_backend.lib.core.content import ContentApi |
|
225
|
|
|
from tracim_backend.lib.core.user import UserApi |
|
226
|
|
|
user = UserApi( |
|
227
|
|
|
None, |
|
228
|
|
|
config=self.config, |
|
229
|
|
|
session=self.session, |
|
230
|
|
|
).get_one(event_actor_id) |
|
231
|
|
|
logger.debug(self, 'Content: {}'.format(event_content_id)) |
|
232
|
|
|
content_api = ContentApi( |
|
233
|
|
|
current_user=user, |
|
234
|
|
|
session=self.session, |
|
235
|
|
|
config=self.config, |
|
236
|
|
|
) |
|
237
|
|
|
content = ContentApi( |
|
238
|
|
|
session=self.session, |
|
239
|
|
|
current_user=user, # nopep8 TODO - use a system user instead of the user that has triggered the event |
|
240
|
|
|
config=self.config, |
|
241
|
|
|
show_archived=True, |
|
242
|
|
|
show_deleted=True, |
|
243
|
|
|
).get_one(event_content_id, content_type_list.Any_SLUG) |
|
244
|
|
|
workspace_api = WorkspaceApi( |
|
245
|
|
|
session=self.session, |
|
246
|
|
|
current_user=user, |
|
247
|
|
|
config=self.config, |
|
248
|
|
|
) |
|
249
|
|
|
workpace_in_context = workspace_api.get_workspace_with_context(workspace_api.get_one(content.workspace_id)) # nopep8 |
|
250
|
|
|
main_content = content.parent if content.type == content_type_list.Comment.slug else content # nopep8 |
|
251
|
|
|
notifiable_roles = WorkspaceApi( |
|
252
|
|
|
current_user=user, |
|
253
|
|
|
session=self.session, |
|
254
|
|
|
config=self.config, |
|
255
|
|
|
).get_notifiable_roles(content.workspace) |
|
256
|
|
|
|
|
257
|
|
|
if len(notifiable_roles) <= 0: |
|
258
|
|
|
logger.info(self, 'Skipping notification as nobody subscribed to in workspace {}'.format(content.workspace.label)) |
|
259
|
|
|
return |
|
260
|
|
|
|
|
261
|
|
|
|
|
262
|
|
|
logger.info(self, 'Sending asynchronous emails to {} user(s)'.format(len(notifiable_roles))) |
|
263
|
|
|
# INFO - D.A. - 2014-11-06 |
|
264
|
|
|
# The following email sender will send emails in the async task queue |
|
265
|
|
|
# This allow to build all mails through current thread but really send them (including SMTP connection) |
|
266
|
|
|
# In the other thread. |
|
267
|
|
|
# |
|
268
|
|
|
# This way, the webserver will return sooner (actually before notification emails are sent |
|
269
|
|
|
async_email_sender = EmailSender( |
|
270
|
|
|
self.config, |
|
271
|
|
|
self._smtp_config, |
|
272
|
|
|
self.config.EMAIL_NOTIFICATION_ACTIVATED |
|
273
|
|
|
) |
|
274
|
|
|
for role in notifiable_roles: |
|
275
|
|
|
logger.info(self, 'Sending email to {}'.format(role.user.email)) |
|
276
|
|
|
translator = Translator(app_config=self.config, default_lang=role.user.lang) # nopep8 |
|
277
|
|
|
_ = translator.get_translation |
|
278
|
|
|
to_addr = formataddr((role.user.display_name, role.user.email)) |
|
279
|
|
|
# INFO - G.M - 2017-11-15 - set content_id in header to permit reply |
|
280
|
|
|
# references can have multiple values, but only one in this case. |
|
281
|
|
|
replyto_addr = self.config.EMAIL_NOTIFICATION_REPLY_TO_EMAIL.replace( # nopep8 |
|
282
|
|
|
'{content_id}', str(main_content.content_id) |
|
283
|
|
|
) |
|
284
|
|
|
|
|
285
|
|
|
reference_addr = self.config.EMAIL_NOTIFICATION_REFERENCES_EMAIL.replace( #nopep8 |
|
286
|
|
|
'{content_id}',str(main_content.content_id) |
|
287
|
|
|
) |
|
288
|
|
|
# |
|
289
|
|
|
# INFO - D.A. - 2014-11-06 |
|
290
|
|
|
# We do not use .format() here because the subject defined in the .ini file |
|
291
|
|
|
# may not include all required labels. In order to avoid partial format() (which result in an exception) |
|
292
|
|
|
# we do use replace and force the use of .__str__() in order to process LazyString objects |
|
293
|
|
|
# |
|
294
|
|
|
subject = self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_SUBJECT |
|
295
|
|
|
subject = subject.replace(EST.WEBSITE_TITLE, self.config.WEBSITE_TITLE.__str__()) |
|
296
|
|
|
subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__()) |
|
297
|
|
|
subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__()) |
|
298
|
|
|
subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__()) |
|
299
|
|
|
reply_to_label = _('{username} & all members of {workspace}').format( # nopep8 |
|
300
|
|
|
username=user.display_name, |
|
301
|
|
|
workspace=main_content.workspace.label) |
|
302
|
|
|
|
|
303
|
|
|
message = MIMEMultipart('alternative') |
|
304
|
|
|
message['Subject'] = subject |
|
305
|
|
|
message['From'] = self._get_sender(user) |
|
306
|
|
|
message['To'] = to_addr |
|
307
|
|
|
message['Reply-to'] = formataddr((reply_to_label, replyto_addr)) |
|
308
|
|
|
# INFO - G.M - 2017-11-15 |
|
309
|
|
|
# References can theorically have label, but in pratice, references |
|
310
|
|
|
# contains only message_id from parents post in thread. |
|
311
|
|
|
# To link this email to a content we create a virtual parent |
|
312
|
|
|
# in reference who contain the content_id. |
|
313
|
|
|
message['References'] = formataddr(('', reference_addr)) |
|
314
|
|
|
content_in_context = content_api.get_content_in_context(content) |
|
315
|
|
|
parent_in_context = None |
|
316
|
|
|
if content.parent_id: |
|
317
|
|
|
parent_in_context = content_api.get_content_in_context(content.parent) # nopep8 |
|
318
|
|
|
body_text = self._build_email_body_for_content( |
|
319
|
|
|
self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, |
|
320
|
|
|
role, |
|
321
|
|
|
content_in_context, |
|
322
|
|
|
parent_in_context, |
|
323
|
|
|
workpace_in_context, |
|
324
|
|
|
user, |
|
325
|
|
|
translator, |
|
326
|
|
|
) |
|
327
|
|
|
body_html = self._build_email_body_for_content( |
|
328
|
|
|
self.config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML, |
|
329
|
|
|
role, |
|
330
|
|
|
content_in_context, |
|
331
|
|
|
parent_in_context, |
|
332
|
|
|
workpace_in_context, |
|
333
|
|
|
user, |
|
334
|
|
|
translator, |
|
335
|
|
|
) |
|
336
|
|
|
|
|
337
|
|
|
part1 = MIMEText(body_text, 'plain', 'utf-8') |
|
338
|
|
|
part2 = MIMEText(body_html, 'html', 'utf-8') |
|
339
|
|
|
# Attach parts into message container. |
|
340
|
|
|
# According to RFC 2046, the last part of a multipart message, in this case |
|
341
|
|
|
# the HTML message, is best and preferred. |
|
342
|
|
|
message.attach(part1) |
|
343
|
|
|
message.attach(part2) |
|
344
|
|
|
|
|
345
|
|
|
self.log_notification( |
|
346
|
|
|
action='CREATED', |
|
347
|
|
|
recipient=message['To'], |
|
348
|
|
|
subject=message['Subject'], |
|
349
|
|
|
config=self.config, |
|
350
|
|
|
) |
|
351
|
|
|
|
|
352
|
|
|
send_email_through( |
|
353
|
|
|
self.config, |
|
354
|
|
|
async_email_sender.send_mail, |
|
355
|
|
|
message |
|
356
|
|
|
) |
|
357
|
|
|
|
|
358
|
|
View Code Duplication |
def notify_created_account( |
|
|
|
|
|
|
359
|
|
|
self, |
|
360
|
|
|
user: User, |
|
361
|
|
|
password: str, |
|
362
|
|
|
) -> None: |
|
363
|
|
|
""" |
|
364
|
|
|
Send created account email to given user. |
|
365
|
|
|
|
|
366
|
|
|
:param password: choosed password |
|
367
|
|
|
:param user: user to notify |
|
368
|
|
|
""" |
|
369
|
|
|
# TODO BS 20160712: Cyclic import |
|
370
|
|
|
logger.debug(self, 'user: {}'.format(user.user_id)) |
|
371
|
|
|
logger.info(self, 'Sending asynchronous email to 1 user ({0})'.format( |
|
372
|
|
|
user.email, |
|
373
|
|
|
)) |
|
374
|
|
|
|
|
375
|
|
|
async_email_sender = EmailSender( |
|
376
|
|
|
self.config, |
|
377
|
|
|
self._smtp_config, |
|
378
|
|
|
self.config.EMAIL_NOTIFICATION_ACTIVATED |
|
379
|
|
|
) |
|
380
|
|
|
|
|
381
|
|
|
subject = \ |
|
382
|
|
|
self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_SUBJECT \ |
|
383
|
|
|
.replace( |
|
384
|
|
|
EST.WEBSITE_TITLE, |
|
385
|
|
|
str(self.config.WEBSITE_TITLE) |
|
386
|
|
|
) |
|
387
|
|
|
message = MIMEMultipart('alternative') |
|
388
|
|
|
message['Subject'] = subject |
|
389
|
|
|
message['From'] = self._get_sender() |
|
390
|
|
|
message['To'] = formataddr((user.get_display_name(), user.email)) |
|
391
|
|
|
|
|
392
|
|
|
text_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT # nopep8 |
|
393
|
|
|
html_template_file_path = self.config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML # nopep8 |
|
394
|
|
|
|
|
395
|
|
|
context = { |
|
396
|
|
|
'user': user, |
|
397
|
|
|
'password': password, |
|
398
|
|
|
'logo_url': get_email_logo_frontend_url(self.config), |
|
399
|
|
|
'login_url': get_login_frontend_url(self.config), |
|
400
|
|
|
} |
|
401
|
|
|
translator = Translator(self.config, default_lang=user.lang) |
|
402
|
|
|
body_text = self._render_template( |
|
403
|
|
|
mako_template_filepath=text_template_file_path, |
|
404
|
|
|
context=context, |
|
405
|
|
|
translator=translator |
|
406
|
|
|
) |
|
407
|
|
|
|
|
408
|
|
|
body_html = self._render_template( |
|
409
|
|
|
mako_template_filepath=html_template_file_path, |
|
410
|
|
|
context=context, |
|
411
|
|
|
translator=translator |
|
412
|
|
|
) |
|
413
|
|
|
|
|
414
|
|
|
part1 = MIMEText(body_text, 'plain', 'utf-8') |
|
415
|
|
|
part2 = MIMEText(body_html, 'html', 'utf-8') |
|
416
|
|
|
|
|
417
|
|
|
# Attach parts into message container. |
|
418
|
|
|
# According to RFC 2046, the last part of a multipart message, |
|
419
|
|
|
# in this case the HTML message, is best and preferred. |
|
420
|
|
|
message.attach(part1) |
|
421
|
|
|
message.attach(part2) |
|
422
|
|
|
|
|
423
|
|
|
send_email_through( |
|
424
|
|
|
config=self.config, |
|
425
|
|
|
sendmail_callable=async_email_sender.send_mail, |
|
426
|
|
|
message=message |
|
427
|
|
|
) |
|
428
|
|
|
|
|
429
|
|
View Code Duplication |
def notify_reset_password( |
|
|
|
|
|
|
430
|
|
|
self, |
|
431
|
|
|
user: User, |
|
432
|
|
|
reset_password_token: str, |
|
433
|
|
|
) -> None: |
|
434
|
|
|
""" |
|
435
|
|
|
Reset password link for user |
|
436
|
|
|
:param user: user to notify |
|
437
|
|
|
:param reset_password_token: token for resetting password |
|
438
|
|
|
""" |
|
439
|
|
|
logger.debug(self, 'user: {}'.format(user.user_id)) |
|
440
|
|
|
logger.info(self, 'Sending asynchronous email to 1 user ({0})'.format( |
|
441
|
|
|
user.email, |
|
442
|
|
|
)) |
|
443
|
|
|
translator = Translator(self.config, default_lang=user.lang) |
|
444
|
|
|
async_email_sender = EmailSender( |
|
445
|
|
|
self.config, |
|
446
|
|
|
self._smtp_config, |
|
447
|
|
|
self.config.EMAIL_NOTIFICATION_ACTIVATED |
|
448
|
|
|
) |
|
449
|
|
|
subject = self.config.EMAIL_NOTIFICATION_RESET_PASSWORD_SUBJECT.replace( |
|
450
|
|
|
EST.WEBSITE_TITLE, |
|
451
|
|
|
str(self.config.WEBSITE_TITLE) |
|
452
|
|
|
) |
|
453
|
|
|
message = MIMEMultipart('alternative') |
|
454
|
|
|
message['Subject'] = subject |
|
455
|
|
|
message['From'] = self._get_sender() |
|
456
|
|
|
message['To'] = formataddr((user.get_display_name(), user.email)) |
|
457
|
|
|
|
|
458
|
|
|
text_template_file_path = self.config.EMAIL_NOTIFICATION_RESET_PASSWORD_TEMPLATE_TEXT # nopep8 |
|
459
|
|
|
html_template_file_path = self.config.EMAIL_NOTIFICATION_RESET_PASSWORD_TEMPLATE_HTML # nopep8 |
|
460
|
|
|
# TODO - G.M - 2018-08-17 - Generate token |
|
461
|
|
|
context = { |
|
462
|
|
|
'user': user, |
|
463
|
|
|
'logo_url': get_email_logo_frontend_url(self.config), |
|
464
|
|
|
'reset_password_url': get_reset_password_frontend_url( |
|
465
|
|
|
self.config, |
|
466
|
|
|
token=reset_password_token, |
|
467
|
|
|
email=user.email, |
|
468
|
|
|
), |
|
469
|
|
|
} |
|
470
|
|
|
body_text = self._render_template( |
|
471
|
|
|
mako_template_filepath=text_template_file_path, |
|
472
|
|
|
context=context, |
|
473
|
|
|
translator=translator, |
|
474
|
|
|
) |
|
475
|
|
|
|
|
476
|
|
|
body_html = self._render_template( |
|
477
|
|
|
mako_template_filepath=html_template_file_path, |
|
478
|
|
|
context=context, |
|
479
|
|
|
translator=translator, |
|
480
|
|
|
) |
|
481
|
|
|
|
|
482
|
|
|
part1 = MIMEText(body_text, 'plain', 'utf-8') |
|
483
|
|
|
part2 = MIMEText(body_html, 'html', 'utf-8') |
|
484
|
|
|
|
|
485
|
|
|
# Attach parts into message container. |
|
486
|
|
|
# According to RFC 2046, the last part of a multipart message, |
|
487
|
|
|
# in this case the HTML message, is best and preferred. |
|
488
|
|
|
message.attach(part1) |
|
489
|
|
|
message.attach(part2) |
|
490
|
|
|
|
|
491
|
|
|
send_email_through( |
|
492
|
|
|
config=self.config, |
|
493
|
|
|
sendmail_callable=async_email_sender.send_mail, |
|
494
|
|
|
message=message |
|
495
|
|
|
) |
|
496
|
|
|
|
|
497
|
|
|
def _render_template( |
|
498
|
|
|
self, |
|
499
|
|
|
mako_template_filepath: str, |
|
500
|
|
|
context: dict, |
|
501
|
|
|
translator: Translator |
|
502
|
|
|
) -> str: |
|
503
|
|
|
""" |
|
504
|
|
|
Render mako template with all needed current variables. |
|
505
|
|
|
|
|
506
|
|
|
:param mako_template_filepath: file path of mako template |
|
507
|
|
|
:param context: dict with template context |
|
508
|
|
|
:return: template rendered string |
|
509
|
|
|
""" |
|
510
|
|
|
|
|
511
|
|
|
template = Template(filename=mako_template_filepath) |
|
512
|
|
|
return template.render( |
|
513
|
|
|
_=translator.get_translation, |
|
514
|
|
|
config=self.config, |
|
515
|
|
|
**context |
|
516
|
|
|
) |
|
517
|
|
|
|
|
518
|
|
|
def _build_context_for_content_update( |
|
519
|
|
|
self, |
|
520
|
|
|
role: UserRoleInWorkspace, |
|
521
|
|
|
content_in_context: ContentInContext, |
|
522
|
|
|
parent_in_context: typing.Optional[ContentInContext], |
|
523
|
|
|
workspace_in_context: WorkspaceInContext, |
|
524
|
|
|
actor: User, |
|
525
|
|
|
translator: Translator |
|
526
|
|
|
): |
|
527
|
|
|
|
|
528
|
|
|
_ = translator.get_translation |
|
529
|
|
|
content = content_in_context.content |
|
530
|
|
|
action = content.get_last_action().id |
|
531
|
|
|
|
|
532
|
|
|
# default values |
|
533
|
|
|
user = role.user |
|
534
|
|
|
workspace = role.workspace |
|
535
|
|
|
workspace_url = workspace_in_context.frontend_url |
|
536
|
|
|
main_title = content.label |
|
537
|
|
|
status_label = content.get_status().label |
|
538
|
|
|
# TODO - G.M - 11-06-2018 - [emailTemplateURL] correct value for status_icon_url # nopep8 |
|
539
|
|
|
status_icon_url = '' |
|
540
|
|
|
role_label = role.role_as_label() |
|
541
|
|
|
content_intro = '<span id="content-intro-username">{}</span> did something.'.format(actor.display_name) # nopep8 |
|
542
|
|
|
content_text = content.description |
|
543
|
|
|
call_to_action_text = 'See more' |
|
544
|
|
|
call_to_action_url = content_in_context.frontend_url |
|
545
|
|
|
logo_url = get_email_logo_frontend_url(self.config) |
|
546
|
|
|
|
|
547
|
|
|
if ActionDescription.CREATION == action: |
|
548
|
|
|
call_to_action_text = _('View online') |
|
549
|
|
|
content_intro = _('<span id="content-intro-username">{}</span> create a content:').format(actor.display_name) # nopep8 |
|
550
|
|
|
|
|
551
|
|
|
if content_type_list.Thread.slug == content.type: |
|
552
|
|
|
if content.get_last_comment_from(actor): |
|
553
|
|
|
content_text = content.get_last_comment_from(actor).description # nopep8 |
|
554
|
|
|
|
|
555
|
|
|
call_to_action_text = _('Answer') |
|
556
|
|
|
content_intro = _('<span id="content-intro-username">{}</span> started a thread entitled:').format(actor.display_name) |
|
557
|
|
|
content_text = '<p id="content-body-intro">{}</p>'.format(content.label) + content_text # nopep8 |
|
558
|
|
|
|
|
559
|
|
|
elif content_type_list.File.slug == content.type: |
|
560
|
|
|
content_intro = _('<span id="content-intro-username">{}</span> added a file entitled:').format(actor.display_name) |
|
561
|
|
|
if content.description: |
|
562
|
|
|
content_text = content.description |
|
563
|
|
|
else: |
|
564
|
|
|
content_text = '<span id="content-body-only-title">{}</span>'.format(content.label) |
|
565
|
|
|
|
|
566
|
|
|
elif content_type_list.Page.slug == content.type: |
|
567
|
|
|
content_intro = _('<span id="content-intro-username">{}</span> added a page entitled:').format(actor.display_name) |
|
568
|
|
|
content_text = '<span id="content-body-only-title">{}</span>'.format(content.label) |
|
569
|
|
|
|
|
570
|
|
|
elif ActionDescription.REVISION == action: |
|
571
|
|
|
content_text = content.description |
|
572
|
|
|
call_to_action_text = _('View online') |
|
573
|
|
|
|
|
574
|
|
|
if content_type_list.File.slug == content.type: |
|
575
|
|
|
content_intro = _('<span id="content-intro-username">{}</span> uploaded a new revision.').format(actor.display_name) |
|
576
|
|
|
content_text = content.description |
|
577
|
|
|
|
|
578
|
|
|
elif ActionDescription.EDITION == action: |
|
579
|
|
|
call_to_action_text = _('View online') |
|
580
|
|
|
|
|
581
|
|
|
if content_type_list.File.slug == content.type: |
|
582
|
|
|
content_intro = _('<span id="content-intro-username">{}</span> updated the file description.').format(actor.display_name) |
|
583
|
|
|
content_text = '<p id="content-body-intro">{}</p>'.format(content.get_label()) + content.description # nopep8 |
|
584
|
|
|
|
|
585
|
|
|
elif content_type_list.Thread.slug == content.type: |
|
586
|
|
|
content_intro = _('<span id="content-intro-username">{}</span> updated the thread description.').format(actor.display_name) |
|
587
|
|
|
previous_revision = content.get_previous_revision() |
|
588
|
|
|
title_diff = '' |
|
589
|
|
|
if previous_revision.label != content.label: |
|
590
|
|
|
title_diff = htmldiff(previous_revision.label, content.label) |
|
591
|
|
|
content_text = str('<p id="content-body-intro">{}</p> {text} {title_diff} {content_diff}').format( |
|
592
|
|
|
text=_('Here is an overview of the changes:'), |
|
593
|
|
|
title_diff=title_diff, |
|
594
|
|
|
content_diff=htmldiff(previous_revision.description, content.description) |
|
595
|
|
|
) |
|
596
|
|
|
elif content_type_list.Page.slug == content.type: |
|
597
|
|
|
content_intro = _('<span id="content-intro-username">{}</span> updated this page.').format(actor.display_name) |
|
598
|
|
|
previous_revision = content.get_previous_revision() |
|
599
|
|
|
title_diff = '' |
|
600
|
|
|
if previous_revision.label != content.label: |
|
601
|
|
|
title_diff = htmldiff(previous_revision.label, content.label) # nopep8 |
|
602
|
|
|
content_text = str('<p id="content-body-intro">{}</p> {text}</p> {title_diff} {content_diff}').format( # nopep8 |
|
603
|
|
|
actor.display_name, |
|
604
|
|
|
text=_('Here is an overview of the changes:'), |
|
605
|
|
|
title_diff=title_diff, |
|
606
|
|
|
content_diff=htmldiff(previous_revision.description, content.description) |
|
607
|
|
|
) |
|
608
|
|
|
|
|
609
|
|
|
elif ActionDescription.STATUS_UPDATE == action: |
|
610
|
|
|
intro_user_msg = _( |
|
611
|
|
|
'<span id="content-intro-username">{}</span> ' |
|
612
|
|
|
'updated the following status:' |
|
613
|
|
|
) |
|
614
|
|
|
intro_body_msg = '<p id="content-body-intro">{}: {}</p>' |
|
615
|
|
|
|
|
616
|
|
|
call_to_action_text = _('View online') |
|
617
|
|
|
content_intro = intro_user_msg.format(actor.display_name) |
|
618
|
|
|
content_text = intro_body_msg.format( |
|
619
|
|
|
content.get_label(), |
|
620
|
|
|
content.get_status().label, |
|
621
|
|
|
) |
|
622
|
|
|
|
|
623
|
|
|
elif ActionDescription.COMMENT == action: |
|
624
|
|
|
call_to_action_text = _('Answer') |
|
625
|
|
|
main_title = parent_in_context.label |
|
626
|
|
|
content_intro = _('<span id="content-intro-username">{}</span> added a comment:').format(actor.display_name) # nopep8 |
|
627
|
|
|
call_to_action_url = parent_in_context.frontend_url |
|
628
|
|
|
|
|
629
|
|
|
if not content_intro and not content_text: |
|
630
|
|
|
# Skip notification, but it's not normal |
|
631
|
|
|
logger.error( |
|
632
|
|
|
self, |
|
633
|
|
|
'A notification is being sent but no content. ' |
|
634
|
|
|
'Here are some debug informations: [content_id: {cid}]' |
|
635
|
|
|
'[action: {act}][author: {actor}]'.format( |
|
636
|
|
|
cid=content.content_id, |
|
637
|
|
|
act=action, |
|
638
|
|
|
actor=actor |
|
639
|
|
|
) |
|
640
|
|
|
) |
|
641
|
|
|
raise EmptyNotificationError('Unexpected empty notification') |
|
642
|
|
|
|
|
643
|
|
|
# FIXME: remove/readapt assert to debug easily broken case |
|
644
|
|
|
assert user |
|
645
|
|
|
assert workspace |
|
646
|
|
|
assert main_title |
|
647
|
|
|
assert status_label |
|
648
|
|
|
# assert status_icon_url |
|
649
|
|
|
assert role_label |
|
650
|
|
|
assert content_intro |
|
651
|
|
|
assert content_text or content_text == content.description |
|
652
|
|
|
assert call_to_action_text |
|
653
|
|
|
assert call_to_action_url |
|
654
|
|
|
assert logo_url |
|
655
|
|
|
|
|
656
|
|
|
return { |
|
657
|
|
|
'user': role.user, |
|
658
|
|
|
'workspace': role.workspace, |
|
659
|
|
|
'workspace_url': workspace_url, |
|
660
|
|
|
'main_title': main_title, |
|
661
|
|
|
'status_label': status_label, |
|
662
|
|
|
'status_icon_url': status_icon_url, |
|
663
|
|
|
'role_label': role_label, |
|
664
|
|
|
'content_intro': content_intro, |
|
665
|
|
|
'content_text': content_text, |
|
666
|
|
|
'call_to_action_text': call_to_action_text, |
|
667
|
|
|
'call_to_action_url': call_to_action_url, |
|
668
|
|
|
'logo_url': logo_url, |
|
669
|
|
|
} |
|
670
|
|
|
|
|
671
|
|
|
def _build_email_body_for_content( |
|
672
|
|
|
self, |
|
673
|
|
|
mako_template_filepath: str, |
|
674
|
|
|
role: UserRoleInWorkspace, |
|
675
|
|
|
content_in_context: ContentInContext, |
|
676
|
|
|
parent_in_context: typing.Optional[ContentInContext], |
|
677
|
|
|
workspace_in_context: WorkspaceInContext, |
|
678
|
|
|
actor: User, |
|
679
|
|
|
translator: Translator |
|
680
|
|
|
) -> str: |
|
681
|
|
|
""" |
|
682
|
|
|
Build an email body and return it as a string |
|
683
|
|
|
:param mako_template_filepath: the absolute path to the mako template |
|
684
|
|
|
to be used for email body building |
|
685
|
|
|
:param role: the role related to user to whom the email must be sent. |
|
686
|
|
|
The role is required (and not the user only) in order to show in the |
|
687
|
|
|
mail why the user receive the notification |
|
688
|
|
|
:param content_in_context: the content item related to the notification |
|
689
|
|
|
:param parent_in_context: parent of the content item related to the |
|
690
|
|
|
notification |
|
691
|
|
|
:param actor: the user at the origin of the action / notification |
|
692
|
|
|
(for example the one who wrote a comment |
|
693
|
|
|
:return: the built email body as string. In case of multipart email, |
|
694
|
|
|
this method must be called one time for text and one time for html |
|
695
|
|
|
""" |
|
696
|
|
|
logger.debug(self, 'Building email content from MAKO template {}'.format(mako_template_filepath)) # nopep8 |
|
697
|
|
|
context = self._build_context_for_content_update( |
|
698
|
|
|
role=role, |
|
699
|
|
|
content_in_context=content_in_context, |
|
700
|
|
|
parent_in_context=parent_in_context, |
|
701
|
|
|
workspace_in_context=workspace_in_context, |
|
702
|
|
|
actor=actor, |
|
703
|
|
|
translator=translator |
|
704
|
|
|
) |
|
705
|
|
|
body_content = self._render_template( |
|
706
|
|
|
mako_template_filepath=mako_template_filepath, |
|
707
|
|
|
context=context, |
|
708
|
|
|
translator=translator, |
|
709
|
|
|
) |
|
710
|
|
|
return body_content |
|
711
|
|
|
|
|
712
|
|
|
|
|
713
|
|
|
def get_email_manager(config: CFG, session: Session): |
|
714
|
|
|
""" |
|
715
|
|
|
:return: EmailManager instance |
|
716
|
|
|
""" |
|
717
|
|
|
# TODO: Find a way to import properly without cyclic import |
|
718
|
|
|
|
|
719
|
|
|
smtp_config = SmtpConfiguration( |
|
720
|
|
|
config.EMAIL_NOTIFICATION_SMTP_SERVER, |
|
721
|
|
|
config.EMAIL_NOTIFICATION_SMTP_PORT, |
|
722
|
|
|
config.EMAIL_NOTIFICATION_SMTP_USER, |
|
723
|
|
|
config.EMAIL_NOTIFICATION_SMTP_PASSWORD |
|
724
|
|
|
) |
|
725
|
|
|
|
|
726
|
|
|
return EmailManager(config=config, smtp_config=smtp_config, session=session) |
|
727
|
|
|
|