Browse Source

Merge branch 'master' of github.com:tracim/tracim

Skylsmoi 6 years ago
parent
commit
3c1511a622
51 changed files with 2507 additions and 1146 deletions
  1. 1 0
      .gitignore
  2. 4 2
      README.md
  3. 0 75
      bin/setup.sh
  4. 3 3
      doc/database.md
  5. 1 1
      install/requirements.postgresql.txt
  6. 5 2
      install/requirements.txt
  7. 20 0
      tracim/development.ini.base
  8. 0 3
      tracim/setup.py
  9. 57 0
      tracim/tracim/config/app_cfg.py
  10. 100 0
      tracim/tracim/controllers/events.py
  11. 2 14
      tracim/tracim/controllers/root.py
  12. BIN
      tracim/tracim/i18n/en/LC_MESSAGES/tracim.mo
  13. 110 69
      tracim/tracim/i18n/en/LC_MESSAGES/tracim.po
  14. 324 895
      tracim/tracim/i18n/fr/LC_MESSAGES/tracim.po
  15. 2 2
      tracim/tracim/lib/base.py
  16. 7 2
      tracim/tracim/lib/content.py
  17. 43 2
      tracim/tracim/lib/daemons.py
  18. 4 3
      tracim/tracim/lib/email.py
  19. 434 0
      tracim/tracim/lib/email_fetcher.py
  20. 0 0
      tracim/tracim/lib/email_processing/__init__.py
  21. 211 0
      tracim/tracim/lib/email_processing/checkers.py
  22. 116 0
      tracim/tracim/lib/email_processing/models.py
  23. 128 0
      tracim/tracim/lib/email_processing/parser.py
  24. 66 0
      tracim/tracim/lib/email_processing/sanitizer.py
  25. 1 0
      tracim/tracim/lib/email_processing/sanitizer_config/attrs_whitelist.py
  26. 1 0
      tracim/tracim/lib/email_processing/sanitizer_config/class_blacklist.py
  27. 1 0
      tracim/tracim/lib/email_processing/sanitizer_config/id_blacklist.py
  28. 1 0
      tracim/tracim/lib/email_processing/sanitizer_config/tag_blacklist.py
  29. 16 0
      tracim/tracim/lib/email_processing/sanitizer_config/tag_whitelist.py
  30. 22 6
      tracim/tracim/lib/notifications.py
  31. 10 4
      tracim/tracim/lib/utils.py
  32. 6 4
      tracim/tracim/lib/webdav/design.py
  33. 2 2
      tracim/tracim/lib/workspace.py
  34. 10 0
      tracim/tracim/model/auth.py
  35. 3 13
      tracim/tracim/model/data.py
  36. 2 1
      tracim/tracim/model/serializers.py
  37. 1 1
      tracim/tracim/templates/admin/user_getall.mak
  38. 1 1
      tracim/tracim/templates/admin/workspace_getall.mak
  39. 1 1
      tracim/tracim/templates/file/edit.mak
  40. 3 3
      tracim/tracim/templates/file/getone.mak
  41. 2 2
      tracim/tracim/templates/folder/getone.mak
  42. 3 1
      tracim/tracim/templates/home.mak
  43. 2 2
      tracim/tracim/templates/page/getone.mak
  44. 2 2
      tracim/tracim/templates/thread/getone.mak
  45. 1 2
      tracim/tracim/templates/widgets/left_menu.mak
  46. 4 4
      tracim/tracim/templates/widgets/table_row.mak
  47. 3 3
      tracim/tracim/tests/functional/test_ldap_restrictions.py
  48. 1 1
      tracim/tracim/tests/functional/test_root.py
  49. 759 0
      tracim/tracim/tests/library/test_email_body_parser.py
  50. 11 0
      tracim/tracim/tests/library/test_email_fetcher.py
  51. 0 20
      update.sh

+ 1 - 0
.gitignore View File

70
 # Temporary files
70
 # Temporary files
71
 *~
71
 *~
72
 *.sqlite
72
 *.sqlite
73
+*.lock
73
 
74
 
74
 # npm packages
75
 # npm packages
75
 /node_modules/
76
 /node_modules/

+ 4 - 2
README.md View File

102
 
102
 
103
 In case you prefer using Docker:
103
 In case you prefer using Docker:
104
 
104
 
105
-    docker run -e DATABASE_TYPE=sqlite -p 80:80 -v /var/tracim/etc:/etc/tracim -v /var/tracim/var:/var/tracim algoo/tracim
105
+    sudo docker run -e DATABASE_TYPE=sqlite \
106
+               -p 80:80 -p 3030:3030 -p 5232:5232 \
107
+               -v /var/tracim/etc:/etc/tracim -v /var/tracim/var:/var/tracim algoo/tracim
106
 
108
 
107
 ## Install Tracim on your server ##
109
 ## Install Tracim on your server ##
108
 
110
 
116
 
118
 
117
 You'll need to install the following packages :
119
 You'll need to install the following packages :
118
 
120
 
119
-    sudo apt install git realpath redis-server \
121
+    sudo apt install git curl realpath redis-server \
120
                      python3 python-virtualenv python3-dev python-pip  python-lxml \
122
                      python3 python-virtualenv python3-dev python-pip  python-lxml \
121
                      build-essential libxml2-dev libxslt1-dev zlib1g-dev libjpeg-dev \
123
                      build-essential libxml2-dev libxslt1-dev zlib1g-dev libjpeg-dev \
122
                      libmagickwand-6.q16-3
124
                      libmagickwand-6.q16-3

+ 0 - 75
bin/setup.sh View File

1
-#!/bin/bash
2
-
3
-POD_BIN_PATH=`dirname $0`
4
-POD_INSTALL_PATH=`dirname ${POD_BIN_PATH}`
5
-POD_INSTALL_FULL_PATH=`realpath ${POD_INSTALL_PATH}`
6
-
7
-echo $POD_BIN_PATH
8
-echo $POD_INSTALL_PATH
9
-echo $POD_INSTALL_FULL_PATH
10
-
11
-OLD_PATH=`pwd`
12
-
13
-
14
-cd ${POD_INSTALL_FULL_PATH}
15
-# virtualenv tg2env
16
-echo
17
-echo "-------------------------"
18
-echo "- initializes virtualenv"
19
-echo "-------------------------"
20
-echo "-> path:        tg2env/"
21
-echo "-> interpreter: python3"
22
-echo
23
-echo
24
-virtualenv -p /usr/bin/python3 tg2env
25
-
26
-echo
27
-echo
28
-echo "-------------------------"
29
-echo "- activates virtualenv"
30
-echo "-------------------------"
31
-source tg2env/bin/activate
32
-echo
33
-echo
34
-
35
-echo
36
-echo
37
-echo "-------------------------"
38
-echo "- installing turbogears"
39
-echo "-------------------------"
40
-pip install -f http://tg.gy/230 tg.devtools
41
-
42
-echo
43
-echo
44
-
45
-echo
46
-echo
47
-echo "-------------------------"
48
-echo "- install dependencies"
49
-echo "-------------------------"
50
-echo "-> psycopg2"
51
-echo "-> pillow"
52
-echo "-> beautifulsoup4"
53
-echo "-> tw.forms"
54
-echo "-> tgext.admin"
55
-pip install psycopg2
56
-pip install pillow
57
-pip install beautifulsoup4
58
-pip install tw.forms
59
-pip install tgext.admin
60
-echo
61
-echo
62
-
63
-echo
64
-echo
65
-echo "-------------------------"
66
-echo "- setup project"
67
-echo "-------------------------"
68
-cd pod/
69
-python setup.py develop
70
-echo
71
-echo
72
-
73
-
74
-
75
-cd ${OLD_PATH}

+ 3 - 3
doc/database.md View File

57
 In this case, delete the user and database and start over:
57
 In this case, delete the user and database and start over:
58
 
58
 
59
     sudo --user=postgres psql \
59
     sudo --user=postgres psql \
60
-         --command="DROP USER tracimuser;" \
61
-         --command="DROP DATABASE tracimdb;"
60
+         --command="DROP DATABASE tracimdb;" \
61
+         --command="DROP USER tracimuser;"
62
 
62
 
63
 [//]: # (The following lines are only necessary to fix permissions on an existing database:)
63
 [//]: # (The following lines are only necessary to fix permissions on an existing database:)
64
 [//]: # (    sudo --user=postgres psql \)
64
 [//]: # (    sudo --user=postgres psql \)
88
 
88
 
89
 Create a database with following command:
89
 Create a database with following command:
90
 
90
 
91
-    CREATE DATABASE tracimdb;
91
+    CREATE DATABASE tracimdb CHARACTER SET = utf8;
92
 
92
 
93
 Create a user with following command:
93
 Create a user with following command:
94
 
94
 

+ 1 - 1
install/requirements.postgresql.txt View File

1
-psycopg2==2.5.4
1
+psycopg2==2.7.3.2

+ 5 - 2
install/requirements.txt View File

17
 alembic==0.8.4
17
 alembic==0.8.4
18
 argparse==1.2.1
18
 argparse==1.2.1
19
 backlash==0.0.7
19
 backlash==0.0.7
20
-beautifulsoup4==4.4.0
20
+beautifulsoup4==4.6.0
21
 caldav==0.4.0
21
 caldav==0.4.0
22
 cliff==2.9.1
22
 cliff==2.9.1
23
 cmd2==0.6.9
23
 cmd2==0.6.9
57
 vobject==0.9.2
57
 vobject==0.9.2
58
 waitress==0.8.9
58
 waitress==0.8.9
59
 who_ldap==3.2.2
59
 who_ldap==3.2.2
60
--e git+https://github.com/algoo/wsgidav.git@py3#egg=wsgidav
60
+wsgidav==2.2.4
61
 zope.interface==4.1.3
61
 zope.interface==4.1.3
62
 zope.sqlalchemy==0.7.6
62
 zope.sqlalchemy==0.7.6
63
 PyYAML
63
 PyYAML
65
 typing==3.5.3.0
65
 typing==3.5.3.0
66
 rq==0.7.1
66
 rq==0.7.1
67
 click==6.7
67
 click==6.7
68
+markdown==2.6.9
69
+email_reply_parser==0.5.9
70
+filelock==2.0.13

+ 20 - 0
tracim/development.ini.base View File

190
 # notifications generated by a user or another one
190
 # notifications generated by a user or another one
191
 email.notification.from.email = noreply+{user_id}@trac.im
191
 email.notification.from.email = noreply+{user_id}@trac.im
192
 email.notification.from.default_label = Tracim Notifications
192
 email.notification.from.default_label = Tracim Notifications
193
+email.notification.reply_to.email = reply+{content_id}@trac.im
194
+email.notification.references.email = thread+{content_id}@trac.im
193
 email.notification.content_update.template.html = %(here)s/tracim/templates/mail/content_update_body_html.mak
195
 email.notification.content_update.template.html = %(here)s/tracim/templates/mail/content_update_body_html.mak
194
 email.notification.content_update.template.text = %(here)s/tracim/templates/mail/content_update_body_text.mak
196
 email.notification.content_update.template.text = %(here)s/tracim/templates/mail/content_update_body_text.mak
195
 email.notification.created_account.template.html = %(here)s/tracim/templates/mail/created_account_body_html.mak
197
 email.notification.created_account.template.html = %(here)s/tracim/templates/mail/created_account_body_html.mak
212
 # email.async.redis.port = 6379
214
 # email.async.redis.port = 6379
213
 # email.async.redis.db = 0
215
 # email.async.redis.db = 0
214
 
216
 
217
+# Email reply configuration
218
+email.reply.activated = False
219
+email.reply.imap.server = your_imap_server
220
+email.reply.imap.port = 993
221
+email.reply.imap.user = your_imap_user
222
+email.reply.imap.password = your_imap_password
223
+email.reply.imap.folder = INBOX
224
+email.reply.imap.use_ssl = true
225
+# Token for communication between mail fetcher and tracim controller
226
+email.reply.token = mysecuretoken
227
+# Delay in seconds between each check
228
+email.reply.check.heartbeat = 60
229
+email.reply.use_html_parsing = true
230
+email.reply.use_txt_parsing = true
231
+# Lockfile path is required for email_reply feature,
232
+# it's just an empty file use to prevent concurrent access to imap unseen mail
233
+email.reply.lockfile_path = %(here)s/email_fetcher.lock
234
+
215
 ## Radical (CalDav server) configuration
235
 ## Radical (CalDav server) configuration
216
 # radicale.server.host = 0.0.0.0
236
 # radicale.server.host = 0.0.0.0
217
 # radicale.server.port = 5232
237
 # radicale.server.port = 5232

+ 0 - 3
tracim/setup.py View File

86
             'mail sender = tracim.command.mail:MailSenderCommend',
86
             'mail sender = tracim.command.mail:MailSenderCommend',
87
         ]
87
         ]
88
     },
88
     },
89
-    dependency_links=[
90
-        'http://github.com/algoo/preview-generator/tarball/master#egg=preview_generator-1.0',
91
-    ],
92
     zip_safe=False,
89
     zip_safe=False,
93
 )
90
 )

+ 57 - 0
tracim/tracim/config/app_cfg.py View File

28
 from tracim.lib.base import logger
28
 from tracim.lib.base import logger
29
 from tracim.lib.daemons import DaemonsManager
29
 from tracim.lib.daemons import DaemonsManager
30
 from tracim.lib.daemons import MailSenderDaemon
30
 from tracim.lib.daemons import MailSenderDaemon
31
+from tracim.lib.daemons import MailFetcherDaemon
31
 from tracim.lib.daemons import RadicaleDaemon
32
 from tracim.lib.daemons import RadicaleDaemon
32
 from tracim.lib.daemons import WsgiDavDaemon
33
 from tracim.lib.daemons import WsgiDavDaemon
33
 from tracim.lib.system import InterruptManager
34
 from tracim.lib.system import InterruptManager
126
     if cfg.EMAIL_PROCESSING_MODE == CFG.CST.ASYNC:
127
     if cfg.EMAIL_PROCESSING_MODE == CFG.CST.ASYNC:
127
         manager.run('mail_sender', MailSenderDaemon)
128
         manager.run('mail_sender', MailSenderDaemon)
128
 
129
 
130
+    if cfg.EMAIL_REPLY_ACTIVATED:
131
+        manager.run('mail_fetcher', MailFetcherDaemon)
132
+
129
 
133
 
130
 def configure_depot():
134
 def configure_depot():
131
     """Configure Depot."""
135
     """Configure Depot."""
299
         self.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL = tg.config.get(
303
         self.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL = tg.config.get(
300
             'email.notification.from.default_label'
304
             'email.notification.from.default_label'
301
         )
305
         )
306
+        self.EMAIL_NOTIFICATION_REPLY_TO_EMAIL = tg.config.get(
307
+            'email.notification.reply_to.email',
308
+        )
309
+        self.EMAIL_NOTIFICATION_REFERENCES_EMAIL = tg.config.get(
310
+            'email.notification.references.email'
311
+        )
302
         self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML = tg.config.get(
312
         self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML = tg.config.get(
303
             'email.notification.content_update.template.html',
313
             'email.notification.content_update.template.html',
304
         )
314
         )
344
             None,
354
             None,
345
         )
355
         )
346
 
356
 
357
+        self.EMAIL_REPLY_ACTIVATED = asbool(tg.config.get(
358
+            'email.reply.activated',
359
+            False,
360
+        ))
361
+
362
+        self.EMAIL_REPLY_IMAP_SERVER = tg.config.get(
363
+            'email.reply.imap.server',
364
+        )
365
+        self.EMAIL_REPLY_IMAP_PORT = tg.config.get(
366
+            'email.reply.imap.port',
367
+        )
368
+        self.EMAIL_REPLY_IMAP_USER = tg.config.get(
369
+            'email.reply.imap.user',
370
+        )
371
+        self.EMAIL_REPLY_IMAP_PASSWORD = tg.config.get(
372
+            'email.reply.imap.password',
373
+        )
374
+        self.EMAIL_REPLY_IMAP_FOLDER = tg.config.get(
375
+            'email.reply.imap.folder',
376
+        )
377
+        self.EMAIL_REPLY_CHECK_HEARTBEAT = int(tg.config.get(
378
+            'email.reply.check.heartbeat',
379
+            60,
380
+        ))
381
+        self.EMAIL_REPLY_TOKEN = tg.config.get(
382
+            'email.reply.token',
383
+        )
384
+        self.EMAIL_REPLY_IMAP_USE_SSL = asbool(tg.config.get(
385
+            'email.reply.imap.use_ssl',
386
+        ))
387
+        self.EMAIL_REPLY_USE_HTML_PARSING = asbool(tg.config.get(
388
+            'email.reply.use_html_parsing',
389
+            True,
390
+        ))
391
+        self.EMAIL_REPLY_USE_TXT_PARSING = asbool(tg.config.get(
392
+            'email.reply.use_txt_parsing',
393
+            True,
394
+        ))
395
+        self.EMAIL_REPLY_LOCKFILE_PATH = tg.config.get(
396
+            'email.reply.lockfile_path',
397
+            ''
398
+        )
399
+        if not self.EMAIL_REPLY_LOCKFILE_PATH and self.EMAIL_REPLY_ACTIVATED:
400
+            raise Exception(
401
+                mandatory_msg.format('email.reply.lockfile_path')
402
+            )
403
+
347
         self.TRACKER_JS_PATH = tg.config.get(
404
         self.TRACKER_JS_PATH = tg.config.get(
348
             'js_tracker_path',
405
             'js_tracker_path',
349
         )
406
         )

+ 100 - 0
tracim/tracim/controllers/events.py View File

1
+import tg
2
+import typing
3
+from tg import request
4
+from tg import Response
5
+from tg import abort
6
+from tg import RestController
7
+from sqlalchemy.orm.exc import NoResultFound
8
+
9
+from tracim.lib.content import ContentApi
10
+from tracim.lib.user import UserApi
11
+from tracim.model.data import ContentType
12
+from tracim.config.app_cfg import CFG
13
+
14
+
15
+class EventRestController(RestController):
16
+
17
+    @tg.expose('json')
18
+    def post(self) -> Response:
19
+        cfg = CFG.get_instance()
20
+
21
+        try:
22
+            json = request.json_body
23
+        except ValueError:
24
+            return Response(
25
+                status=400,
26
+                json_body={'msg': 'Bad json'},
27
+            )
28
+
29
+        if json.get('token', None) != cfg.EMAIL_REPLY_TOKEN:
30
+            # TODO - G.M - 2017-11-23 - Switch to status 403 ?
31
+            # 403 is a better status code in this case.
32
+            # 403 status response can't now return clean json, because they are
33
+            # handled somewhere else to return html.
34
+            return Response(
35
+                status=400,
36
+                json_body={'msg': 'Invalid token'}
37
+            )
38
+
39
+        if 'user_mail' not in json:
40
+            return Response(
41
+                status=400,
42
+                json_body={'msg': 'Bad json: user_mail is required'}
43
+            )
44
+
45
+        if 'content_id' not in json:
46
+            return Response(
47
+                status=400,
48
+                json_body={'msg': 'Bad json: content_id is required'}
49
+            )
50
+
51
+        if 'payload' not in json:
52
+            return Response(
53
+                status=400,
54
+                json_body={'msg': 'Bad json: payload is required'}
55
+            )
56
+
57
+        uapi = UserApi(None)
58
+        try:
59
+            user = uapi.get_one_by_email(json['user_mail'])
60
+        except NoResultFound:
61
+            return Response(
62
+                status=400,
63
+                json_body={'msg': 'Unknown user email'},
64
+            )
65
+        api = ContentApi(user)
66
+
67
+        try:
68
+            thread = api.get_one(json['content_id'],
69
+                                 content_type=ContentType.Any)
70
+        except NoResultFound:
71
+            return Response(
72
+                status=400,
73
+                json_body={'msg': 'Unknown content_id'},
74
+            )
75
+
76
+        # INFO - G.M - 2017-11-17
77
+        # When content_id is a sub-elem of a main content like Comment,
78
+        # Attach the thread to the main content.
79
+        if thread.type == ContentType.Comment:
80
+            thread = thread.parent
81
+        if thread.type == ContentType.Folder:
82
+            return Response(
83
+                status=400,
84
+                json_body={'msg': 'comment for folder not allowed'},
85
+            )
86
+        if 'content' in json['payload']:
87
+            api.create_comment(
88
+                workspace=thread.workspace,
89
+                parent=thread,
90
+                content=json['payload']['content'],
91
+                do_save=True,
92
+            )
93
+            return Response(
94
+                status=204,
95
+            )
96
+        else:
97
+            return Response(
98
+                status=400,
99
+                json_body={'msg': 'No content to add new comment'},
100
+            )

+ 2 - 14
tracim/tracim/controllers/root.py View File

22
 from tracim.controllers.previews import PreviewsController
22
 from tracim.controllers.previews import PreviewsController
23
 from tracim.controllers.user import UserRestController
23
 from tracim.controllers.user import UserRestController
24
 from tracim.controllers.workspace import UserWorkspaceRestController
24
 from tracim.controllers.workspace import UserWorkspaceRestController
25
+from tracim.controllers.events import EventRestController
25
 from tracim.lib import CST
26
 from tracim.lib import CST
26
 from tracim.lib.base import logger
27
 from tracim.lib.base import logger
27
 from tracim.lib.content import ContentApi
28
 from tracim.lib.content import ContentApi
61
     previews = PreviewsController()
62
     previews = PreviewsController()
62
 
63
 
63
     content = ContentController()
64
     content = ContentController()
64
-
65
+    events = EventRestController()
65
     # api
66
     # api
66
     api = APIController()
67
     api = APIController()
67
 
68
 
154
         fake_api.favorites = Context(CTX.CONTENT_LIST).toDict(items, 'contents', 'nb')
155
         fake_api.favorites = Context(CTX.CONTENT_LIST).toDict(items, 'contents', 'nb')
155
         return DictLikeClass(fake_api=fake_api)
156
         return DictLikeClass(fake_api=fake_api)
156
 
157
 
157
-        # user_id = tmpl_context.current_user.user_id
158
-        #
159
-        # current_user = tmpl_context.current_user
160
-        # assert user_id==current_user.user_id
161
-        # api = UserApi(current_user)
162
-        # current_user = api.get_one(current_user.user_id)
163
-        # dictified_user = Context(CTX.USER).toDict(current_user, 'user')
164
-        # current_user_content = Context(CTX.CURRENT_USER).toDict(tmpl_context.current_user)
165
-        # fake_api_content = DictLikeClass(current_user=current_user_content)
166
-        # fake_api = Context(CTX.WORKSPACE).toDict(fake_api_content)
167
-        #
168
-        # return DictLikeClass(result = dictified_user, fake_api=fake_api)
169
-
170
     @require(predicates.not_anonymous())
158
     @require(predicates.not_anonymous())
171
     @expose('tracim.templates.search.display')
159
     @expose('tracim.templates.search.display')
172
     def search(self, keywords=''):
160
     def search(self, keywords=''):

BIN
tracim/tracim/i18n/en/LC_MESSAGES/tracim.mo View File


+ 110 - 69
tracim/tracim/i18n/en/LC_MESSAGES/tracim.po View File

7
 msgstr ""
7
 msgstr ""
8
 "Project-Id-Version: tracim 1.0.0\n"
8
 "Project-Id-Version: tracim 1.0.0\n"
9
 "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
9
 "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
10
-"POT-Creation-Date: 2017-09-05 16:27+0200\n"
10
+"POT-Creation-Date: 2017-11-03 18:06+0100\n"
11
 "PO-Revision-Date: 2017-09-05 16:31+0200\n"
11
 "PO-Revision-Date: 2017-09-05 16:31+0200\n"
12
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
12
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
13
 "Language: en\n"
13
 "Language: en\n"
242
 msgid "Notification disabled for workspace {}"
242
 msgid "Notification disabled for workspace {}"
243
 msgstr ""
243
 msgstr ""
244
 
244
 
245
-#: tracim/controllers/user.py:100 tracim/controllers/admin/user.py:207
245
+#: tracim/controllers/user.py:100 tracim/controllers/admin/user.py:202
246
 msgid "Empty password is not allowed."
246
 msgid "Empty password is not allowed."
247
 msgstr ""
247
 msgstr ""
248
 
248
 
250
 msgid "The current password you typed is wrong"
250
 msgid "The current password you typed is wrong"
251
 msgstr ""
251
 msgstr ""
252
 
252
 
253
-#: tracim/controllers/user.py:108 tracim/controllers/admin/user.py:211
253
+#: tracim/controllers/user.py:108 tracim/controllers/admin/user.py:206
254
 msgid "New passwords do not match."
254
 msgid "New passwords do not match."
255
 msgstr ""
255
 msgstr ""
256
 
256
 
257
-#: tracim/controllers/user.py:115
257
+#: tracim/controllers/user.py:114
258
 msgid "Your password has been changed"
258
 msgid "Your password has been changed"
259
 msgstr ""
259
 msgstr ""
260
 
260
 
261
-#: tracim/controllers/user.py:183
261
+#: tracim/controllers/user.py:182
262
 msgid "Email already in use"
262
 msgid "Email already in use"
263
 msgstr ""
263
 msgstr ""
264
 
264
 
265
-#: tracim/controllers/user.py:197
265
+#: tracim/controllers/user.py:196
266
 msgid "profile updated."
266
 msgid "profile updated."
267
 msgstr ""
267
 msgstr ""
268
 
268
 
269
-#: tracim/controllers/admin/user.py:91
269
+#: tracim/controllers/admin/user.py:85
270
 msgid "You can't change your own profile"
270
 msgid "You can't change your own profile"
271
 msgstr ""
271
 msgstr ""
272
 
272
 
273
-#: tracim/controllers/admin/user.py:98 tracim/controllers/admin/user.py:149
273
+#: tracim/controllers/admin/user.py:91 tracim/controllers/admin/user.py:144
274
 msgid "Unknown profile"
274
 msgid "Unknown profile"
275
 msgstr ""
275
 msgstr ""
276
 
276
 
277
-#: tracim/controllers/admin/user.py:105
277
+#: tracim/controllers/admin/user.py:99
278
 msgid "User updated."
278
 msgid "User updated."
279
 msgstr ""
279
 msgstr ""
280
 
280
 
281
-#: tracim/controllers/admin/user.py:121
281
+#: tracim/controllers/admin/user.py:115
282
 msgid "User {} is now a basic user"
282
 msgid "User {} is now a basic user"
283
 msgstr ""
283
 msgstr ""
284
 
284
 
285
-#: tracim/controllers/admin/user.py:134
285
+#: tracim/controllers/admin/user.py:128
286
 msgid "User {} can now workspaces"
286
 msgid "User {} can now workspaces"
287
 msgstr ""
287
 msgstr ""
288
 
288
 
289
-#: tracim/controllers/admin/user.py:145
289
+#: tracim/controllers/admin/user.py:138
290
 msgid "User {} is now an administrator"
290
 msgid "User {} is now an administrator"
291
 msgstr ""
291
 msgstr ""
292
 
292
 
293
-#: tracim/controllers/admin/user.py:218
293
+#: tracim/controllers/admin/user.py:212
294
 msgid "The password has been changed"
294
 msgid "The password has been changed"
295
 msgstr ""
295
 msgstr ""
296
 
296
 
297
-#: tracim/controllers/admin/user.py:246
297
+#: tracim/controllers/admin/user.py:241
298
 msgid "User {}: notification enabled for workspace {}"
298
 msgid "User {}: notification enabled for workspace {}"
299
 msgstr ""
299
 msgstr ""
300
 
300
 
301
-#: tracim/controllers/admin/user.py:260
301
+#: tracim/controllers/admin/user.py:255
302
 msgid "User {}: notification disabled for workspace {}"
302
 msgid "User {}: notification disabled for workspace {}"
303
 msgstr ""
303
 msgstr ""
304
 
304
 
305
-#: tracim/controllers/admin/user.py:326
305
+#: tracim/controllers/admin/user.py:322
306
 msgid "A user with email address \"{}\" already exists."
306
 msgid "A user with email address \"{}\" already exists."
307
 msgstr ""
307
 msgstr ""
308
 
308
 
309
-#: tracim/controllers/admin/user.py:358
309
+#: tracim/controllers/admin/user.py:357
310
+msgid "User {0} created but email was not sent to {1}"
311
+msgstr ""
312
+
313
+#: tracim/controllers/admin/user.py:360
310
 msgid "User {} created."
314
 msgid "User {} created."
311
 msgstr ""
315
 msgstr ""
312
 
316
 
314
 msgid "User {} updated."
318
 msgid "User {} updated."
315
 msgstr ""
319
 msgstr ""
316
 
320
 
317
-#: tracim/controllers/admin/user.py:436
321
+#: tracim/controllers/admin/user.py:435
318
 msgid "User {} enabled."
322
 msgid "User {} enabled."
319
 msgstr ""
323
 msgstr ""
320
 
324
 
321
-#: tracim/controllers/admin/user.py:449
325
+#: tracim/controllers/admin/user.py:448
322
 msgid "You can't de-activate your own account"
326
 msgid "You can't de-activate your own account"
323
 msgstr ""
327
 msgstr ""
324
 
328
 
325
-#: tracim/controllers/admin/user.py:454
329
+#: tracim/controllers/admin/user.py:453
326
 msgid "User {} disabled"
330
 msgid "User {} disabled"
327
 msgstr ""
331
 msgstr ""
328
 
332
 
340
 msgid "User {} restored in workspace {} as {}"
344
 msgid "User {} restored in workspace {} as {}"
341
 msgstr ""
345
 msgstr ""
342
 
346
 
343
-#: tracim/controllers/admin/workspace.py:92
347
+#: tracim/controllers/admin/workspace.py:96
344
 msgid "User {} added to workspace {} as {}"
348
 msgid "User {} added to workspace {} as {}"
345
 msgstr ""
349
 msgstr ""
346
 
350
 
347
-#: tracim/controllers/admin/workspace.py:118
351
+#: tracim/controllers/admin/workspace.py:129
348
 msgid "You can't change your own role"
352
 msgid "You can't change your own role"
349
 msgstr ""
353
 msgstr ""
350
 
354
 
351
-#: tracim/controllers/admin/workspace.py:122
355
+#: tracim/controllers/admin/workspace.py:133
352
 msgid "Unknown role"
356
 msgid "Unknown role"
353
 msgstr ""
357
 msgstr ""
354
 
358
 
355
-#: tracim/controllers/admin/workspace.py:127
359
+#: tracim/controllers/admin/workspace.py:138
356
 msgid "No change found."
360
 msgid "No change found."
357
 msgstr ""
361
 msgstr ""
358
 
362
 
359
-#: tracim/controllers/admin/workspace.py:209
363
+#: tracim/controllers/admin/workspace.py:224
360
 msgid "{} workspace created."
364
 msgid "{} workspace created."
361
 msgstr ""
365
 msgstr ""
362
 
366
 
363
-#: tracim/controllers/admin/workspace.py:245
367
+#: tracim/controllers/admin/workspace.py:260
364
 msgid "{} workspace updated."
368
 msgid "{} workspace updated."
365
 msgstr ""
369
 msgstr ""
366
 
370
 
367
-#: tracim/controllers/admin/workspace.py:270
371
+#: tracim/controllers/admin/workspace.py:285
368
 msgid ""
372
 msgid ""
369
 "{} workspace deleted. In case of error, you can <a class=\"alert-link\" "
373
 "{} workspace deleted. In case of error, you can <a class=\"alert-link\" "
370
 "href=\"{}\">restore it</a>."
374
 "href=\"{}\">restore it</a>."
371
 msgstr ""
375
 msgstr ""
372
 
376
 
373
-#: tracim/controllers/admin/workspace.py:283
377
+#: tracim/controllers/admin/workspace.py:298
374
 msgid "{} workspace restored."
378
 msgid "{} workspace restored."
375
 msgstr ""
379
 msgstr ""
376
 
380
 
393
 msgid "Workspaces"
397
 msgid "Workspaces"
394
 msgstr ""
398
 msgstr ""
395
 
399
 
396
-#: tracim/lib/content.py:870
400
+#: tracim/lib/content.py:875
397
 msgid "The content did not changed"
401
 msgid "The content did not changed"
398
 msgstr ""
402
 msgstr ""
399
 
403
 
400
-#: tracim/lib/content.py:1151
404
+#: tracim/lib/content.py:1156
401
 msgid "New folder"
405
 msgid "New folder"
402
 msgstr ""
406
 msgstr ""
403
 
407
 
404
-#: tracim/lib/content.py:1156
408
+#: tracim/lib/content.py:1161
405
 msgid "New folder {0}"
409
 msgid "New folder {0}"
406
 msgstr ""
410
 msgstr ""
407
 
411
 
408
-#: tracim/lib/helpers.py:88
412
+#: tracim/lib/helpers.py:87
409
 msgid "{date} at {time}"
413
 msgid "{date} at {time}"
410
 msgstr ""
414
 msgstr ""
411
 
415
 
412
-#: tracim/lib/notifications.py:309
416
+#: tracim/lib/notifications.py:339
413
 msgid "<span id=\"content-intro-username\">{}</span> added a comment:"
417
 msgid "<span id=\"content-intro-username\">{}</span> added a comment:"
414
 msgstr ""
418
 msgstr ""
415
 
419
 
416
-#: tracim/lib/notifications.py:311 tracim/lib/notifications.py:320
420
+#: tracim/lib/notifications.py:341 tracim/lib/notifications.py:350
417
 msgid "Answer"
421
 msgid "Answer"
418
 msgstr ""
422
 msgstr ""
419
 
423
 
420
-#: tracim/lib/notifications.py:317 tracim/lib/notifications.py:338
421
-#: tracim/lib/notifications.py:371 tracim/lib/notifications.py:379
424
+#: tracim/lib/notifications.py:347 tracim/lib/notifications.py:368
425
+#: tracim/lib/notifications.py:401 tracim/lib/notifications.py:409
422
 msgid "View online"
426
 msgid "View online"
423
 msgstr ""
427
 msgstr ""
424
 
428
 
425
-#: tracim/lib/notifications.py:321
429
+#: tracim/lib/notifications.py:351
426
 msgid "<span id=\"content-intro-username\">{}</span> started a thread entitled:"
430
 msgid "<span id=\"content-intro-username\">{}</span> started a thread entitled:"
427
 msgstr ""
431
 msgstr ""
428
 
432
 
429
-#: tracim/lib/notifications.py:326
433
+#: tracim/lib/notifications.py:356
430
 msgid "<span id=\"content-intro-username\">{}</span> added a file entitled:"
434
 msgid "<span id=\"content-intro-username\">{}</span> added a file entitled:"
431
 msgstr ""
435
 msgstr ""
432
 
436
 
433
-#: tracim/lib/notifications.py:333
437
+#: tracim/lib/notifications.py:363
434
 msgid "<span id=\"content-intro-username\">{}</span> added a page entitled:"
438
 msgid "<span id=\"content-intro-username\">{}</span> added a page entitled:"
435
 msgstr ""
439
 msgstr ""
436
 
440
 
437
-#: tracim/lib/notifications.py:341
441
+#: tracim/lib/notifications.py:371
438
 msgid "<span id=\"content-intro-username\">{}</span> uploaded a new revision."
442
 msgid "<span id=\"content-intro-username\">{}</span> uploaded a new revision."
439
 msgstr ""
443
 msgstr ""
440
 
444
 
441
-#: tracim/lib/notifications.py:345
445
+#: tracim/lib/notifications.py:375
442
 msgid "<span id=\"content-intro-username\">{}</span> updated this page."
446
 msgid "<span id=\"content-intro-username\">{}</span> updated this page."
443
 msgstr ""
447
 msgstr ""
444
 
448
 
445
-#: tracim/lib/notifications.py:350 tracim/lib/notifications.py:360
449
+#: tracim/lib/notifications.py:380 tracim/lib/notifications.py:390
446
 msgid "<p id=\"content-body-intro\">Here is an overview of the changes:</p>"
450
 msgid "<p id=\"content-body-intro\">Here is an overview of the changes:</p>"
447
 msgstr ""
451
 msgstr ""
448
 
452
 
449
-#: tracim/lib/notifications.py:355
453
+#: tracim/lib/notifications.py:385
450
 msgid ""
454
 msgid ""
451
 "<span id=\"content-intro-username\">{}</span> updated the thread "
455
 "<span id=\"content-intro-username\">{}</span> updated the thread "
452
 "description."
456
 "description."
453
 msgstr ""
457
 msgstr ""
454
 
458
 
455
-#: tracim/lib/notifications.py:374
459
+#: tracim/lib/notifications.py:404
456
 msgid ""
460
 msgid ""
457
 "<span id=\"content-intro-username\">{}</span> updated the file "
461
 "<span id=\"content-intro-username\">{}</span> updated the file "
458
 "description."
462
 "description."
459
 msgstr ""
463
 msgstr ""
460
 
464
 
461
-#: tracim/lib/notifications.py:380
465
+#: tracim/lib/notifications.py:410
462
 msgid ""
466
 msgid ""
463
 "<span id=\"content-intro-username\">{}</span> updated the following "
467
 "<span id=\"content-intro-username\">{}</span> updated the following "
464
 "status:"
468
 "status:"
472
 msgid "You're not allowed to access this resource"
476
 msgid "You're not allowed to access this resource"
473
 msgstr ""
477
 msgstr ""
474
 
478
 
475
-#: tracim/lib/workspace.py:192 tracim/templates/home.mak:134
479
+#: tracim/lib/workspace.py:208 tracim/templates/home.mak:134
476
 #: tracim/templates/master_no_toolbar_no_login.mak:109
480
 #: tracim/templates/master_no_toolbar_no_login.mak:109
477
 #: tracim/templates/admin/user_getone.mak:72
481
 #: tracim/templates/admin/user_getone.mak:72
478
 #: tracim/templates/admin/workspace_getall.mak:73
482
 #: tracim/templates/admin/workspace_getall.mak:73
479
 msgid "Workspace"
483
 msgid "Workspace"
480
 msgstr ""
484
 msgstr ""
481
 
485
 
482
-#: tracim/lib/workspace.py:195 tracim/templates/admin/workspace_getone.mak:13
486
+#: tracim/lib/workspace.py:211 tracim/templates/admin/workspace_getone.mak:13
483
 #: tracim/templates/admin/workspace_getone.mak:27
487
 #: tracim/templates/admin/workspace_getone.mak:27
484
 msgid "Workspace {}"
488
 msgid "Workspace {}"
485
 msgstr ""
489
 msgstr ""
486
 
490
 
487
-#: tracim/model/auth.py:97
491
+#: tracim/model/auth.py:101
488
 msgid "Nobody"
492
 msgid "Nobody"
489
 msgstr ""
493
 msgstr ""
490
 
494
 
491
-#: tracim/model/auth.py:98 tracim/templates/master_authenticated.mak:137
495
+#: tracim/model/auth.py:102 tracim/templates/master_authenticated.mak:137
492
 #: tracim/templates/master_no_toolbar_no_login.mak:114
496
 #: tracim/templates/master_no_toolbar_no_login.mak:114
493
 #: tracim/templates/admin/user_getall.mak:11
497
 #: tracim/templates/admin/user_getall.mak:11
494
 #: tracim/templates/admin/user_getall.mak:20
498
 #: tracim/templates/admin/user_getall.mak:20
496
 msgid "Users"
500
 msgid "Users"
497
 msgstr ""
501
 msgstr ""
498
 
502
 
499
-#: tracim/model/auth.py:99
503
+#: tracim/model/auth.py:103
500
 msgid "Global managers"
504
 msgid "Global managers"
501
 msgstr ""
505
 msgstr ""
502
 
506
 
503
-#: tracim/model/auth.py:100
507
+#: tracim/model/auth.py:104
504
 msgid "Administrators"
508
 msgid "Administrators"
505
 msgstr ""
509
 msgstr ""
506
 
510
 
1046
 #: tracim/templates/file/edit.mak:13 tracim/templates/file/edit.mak:14
1050
 #: tracim/templates/file/edit.mak:13 tracim/templates/file/edit.mak:14
1047
 #: tracim/templates/folder/getone.mak:151 tracim/templates/page/edit.mak:13
1051
 #: tracim/templates/folder/getone.mak:151 tracim/templates/page/edit.mak:13
1048
 #: tracim/templates/page/edit.mak:14 tracim/templates/page/forms.mak:16
1052
 #: tracim/templates/page/edit.mak:14 tracim/templates/page/forms.mak:16
1049
-#: tracim/templates/workspace/getone.mak:227
1053
+#: tracim/templates/workspace/getone.mak:222
1050
 msgid "Title"
1054
 msgid "Title"
1051
 msgstr ""
1055
 msgstr ""
1052
 
1056
 
1053
 #: tracim/templates/user_workspace_forms.mak:54
1057
 #: tracim/templates/user_workspace_forms.mak:54
1054
-#: tracim/templates/file/edit.mak:17 tracim/templates/page/edit.mak:17
1055
-#: tracim/templates/page/forms.mak:20 tracim/templates/workspace/getone.mak:158
1058
+#: tracim/templates/page/edit.mak:17 tracim/templates/page/forms.mak:20
1059
+#: tracim/templates/workspace/getone.mak:158
1056
 msgid "Content"
1060
 msgid "Content"
1057
 msgstr ""
1061
 msgstr ""
1058
 
1062
 
1145
 
1149
 
1146
 #: tracim/templates/user_workspace_widgets.mak:80
1150
 #: tracim/templates/user_workspace_widgets.mak:80
1147
 #: tracim/templates/folder/getone.mak:154
1151
 #: tracim/templates/folder/getone.mak:154
1148
-#: tracim/templates/workspace/getone.mak:226
1152
+#: tracim/templates/workspace/getone.mak:221
1149
 msgid "Type"
1153
 msgid "Type"
1150
 msgstr ""
1154
 msgstr ""
1151
 
1155
 
1152
 #: tracim/templates/user_workspace_widgets.mak:82
1156
 #: tracim/templates/user_workspace_widgets.mak:82
1153
 #: tracim/templates/folder/getone.mak:152
1157
 #: tracim/templates/folder/getone.mak:152
1154
-#: tracim/templates/workspace/getone.mak:228
1158
+#: tracim/templates/workspace/getone.mak:223
1155
 msgid "Status"
1159
 msgid "Status"
1156
 msgstr ""
1160
 msgstr ""
1157
 
1161
 
1345
 
1349
 
1346
 #: tracim/templates/admin/workspace_getall.mak:37
1350
 #: tracim/templates/admin/workspace_getall.mak:37
1347
 #: tracim/templates/admin/workspace_getall.mak:74
1351
 #: tracim/templates/admin/workspace_getall.mak:74
1348
-#: tracim/templates/thread/edit.mak:18 tracim/templates/workspace/edit.mak:17
1352
+#: tracim/templates/file/edit.mak:17 tracim/templates/thread/edit.mak:18
1353
+#: tracim/templates/workspace/edit.mak:17
1349
 msgid "Description"
1354
 msgid "Description"
1350
 msgstr ""
1355
 msgstr ""
1351
 
1356
 
1473
 "{last_modification_author})"
1478
 "{last_modification_author})"
1474
 msgstr ""
1479
 msgstr ""
1475
 
1480
 
1476
-#: tracim/templates/file/getone.mak:69 tracim/templates/folder/getone.mak:67
1477
-#: tracim/templates/thread/getone.mak:69
1478
-msgid "Vous consultez <b>une version archivée</b> de la page courante."
1481
+#: tracim/templates/file/getone.mak:69
1482
+msgid "You are looking at an <b>archived file</b>."
1479
 msgstr ""
1483
 msgstr ""
1480
 
1484
 
1481
-#: tracim/templates/file/getone.mak:76 tracim/templates/folder/getone.mak:74
1482
-#: tracim/templates/thread/getone.mak:78
1483
-msgid "Vous consultez <b>une version supprimée</b> de la page courante."
1485
+#: tracim/templates/file/getone.mak:76
1486
+msgid "You are looking at a <b>deleted file</b>."
1484
 msgstr ""
1487
 msgstr ""
1485
 
1488
 
1486
 #: tracim/templates/file/getone.mak:85
1489
 #: tracim/templates/file/getone.mak:85
1607
 msgid "folder created on {date} at {time} by <b>{author}</b>"
1610
 msgid "folder created on {date} at {time} by <b>{author}</b>"
1608
 msgstr ""
1611
 msgstr ""
1609
 
1612
 
1613
+#: tracim/templates/folder/getone.mak:67
1614
+msgid "You are looking at an <b>archived folder</b>."
1615
+msgstr ""
1616
+
1617
+#: tracim/templates/folder/getone.mak:74
1618
+msgid "You are looking at a <b>deleted folder</b>."
1619
+msgstr ""
1620
+
1610
 #: tracim/templates/folder/getone.mak:89
1621
 #: tracim/templates/folder/getone.mak:89
1611
 #: tracim/templates/workspace/getone.mak:163
1622
 #: tracim/templates/workspace/getone.mak:163
1612
 msgid "New ..."
1623
 msgid "New ..."
1613
 msgstr ""
1624
 msgstr ""
1614
 
1625
 
1615
 #: tracim/templates/folder/getone.mak:111
1626
 #: tracim/templates/folder/getone.mak:111
1616
-#: tracim/templates/workspace/getone.mak:201
1627
+#: tracim/templates/workspace/getone.mak:197
1617
 msgid "hide..."
1628
 msgid "hide..."
1618
 msgstr ""
1629
 msgstr ""
1619
 
1630
 
1620
 #: tracim/templates/folder/getone.mak:121
1631
 #: tracim/templates/folder/getone.mak:121
1621
-#: tracim/templates/workspace/getone.mak:210
1632
+#: tracim/templates/workspace/getone.mak:206
1622
 msgid "filter..."
1633
 msgid "filter..."
1623
 msgstr ""
1634
 msgstr ""
1624
 
1635
 
1625
 #: tracim/templates/folder/getone.mak:146
1636
 #: tracim/templates/folder/getone.mak:146
1626
-#: tracim/templates/workspace/getone.mak:221
1637
+#: tracim/templates/workspace/getone.mak:216
1627
 msgid "This folder has not yet content."
1638
 msgid "This folder has not yet content."
1628
 msgstr ""
1639
 msgstr ""
1629
 
1640
 
1772
 msgstr ""
1783
 msgstr ""
1773
 
1784
 
1774
 #: tracim/templates/page/getone.mak:67
1785
 #: tracim/templates/page/getone.mak:67
1775
-msgid "You are viewing <b>an archived version</b> of the current page."
1786
+msgid "You are looking at an <b>archived page</b>."
1776
 msgstr ""
1787
 msgstr ""
1777
 
1788
 
1778
 #: tracim/templates/page/getone.mak:74
1789
 #: tracim/templates/page/getone.mak:74
1779
-msgid "You are viewing <b>a deleted version</b> of the current page."
1790
+msgid "You are looking at a <b>deleted page</b>."
1780
 msgstr ""
1791
 msgstr ""
1781
 
1792
 
1782
 #: tracim/templates/page/getone.mak:83
1793
 #: tracim/templates/page/getone.mak:83
1909
 msgid "page created on {date} at {time} by <b>{author}</b>"
1920
 msgid "page created on {date} at {time} by <b>{author}</b>"
1910
 msgstr ""
1921
 msgstr ""
1911
 
1922
 
1923
+#: tracim/templates/thread/getone.mak:69
1924
+msgid "You are looking at an <b>archived thread</b>."
1925
+msgstr ""
1926
+
1927
+#: tracim/templates/thread/getone.mak:78
1928
+msgid "You are looking at a <b>deleted thread</b>."
1929
+msgstr ""
1930
+
1912
 #: tracim/templates/thread/getone.mak:119
1931
 #: tracim/templates/thread/getone.mak:119
1913
 msgid "Invert order"
1932
 msgid "Invert order"
1914
 msgstr ""
1933
 msgstr ""
1968
 msgid "Remove this user from the current workspace"
1987
 msgid "Remove this user from the current workspace"
1969
 msgstr ""
1988
 msgstr ""
1970
 
1989
 
1990
+#: tracim/templates/widgets/table_row.mak:68
1991
+#: tracim/templates/widgets/table_row.mak:98
1992
+msgid "archived"
1993
+msgstr ""
1994
+
1995
+#: tracim/templates/widgets/table_row.mak:70
1996
+#: tracim/templates/widgets/table_row.mak:100
1997
+msgid "deleted"
1998
+msgstr ""
1999
+
1971
 #: tracim/templates/widgets/ui.mak:12
2000
 #: tracim/templates/widgets/ui.mak:12
1972
 msgid "Show trashed items"
2001
 msgid "Show trashed items"
1973
 msgstr ""
2002
 msgstr ""
2054
 msgid "You are not allowed to create content"
2083
 msgid "You are not allowed to create content"
2055
 msgstr ""
2084
 msgstr ""
2056
 
2085
 
2057
-#: tracim/templates/workspace/getone.mak:229
2086
+#: tracim/templates/workspace/getone.mak:224
2058
 msgid "Remarques"
2087
 msgid "Remarques"
2059
 msgstr ""
2088
 msgstr ""
2060
 
2089
 
2066
 msgid "Delete current workspace"
2095
 msgid "Delete current workspace"
2067
 msgstr ""
2096
 msgstr ""
2068
 
2097
 
2098
+#~ msgid "Vous consultez <b>une version archivée</b> de la page courante."
2099
+#~ msgstr ""
2100
+
2101
+#~ msgid "Vous consultez <b>une version supprimée</b> de la page courante."
2102
+#~ msgstr ""
2103
+
2104
+#~ msgid "You are viewing <b>an archived version</b> of the current page."
2105
+#~ msgstr ""
2106
+
2107
+#~ msgid "You are viewing <b>a deleted version</b> of the current page."
2108
+#~ msgstr ""
2109
+

File diff suppressed because it is too large
+ 324 - 895
tracim/tracim/i18n/fr/LC_MESSAGES/tracim.po


+ 2 - 2
tracim/tracim/lib/base.py View File

125
     def debug(self, instance_or_class, message):
125
     def debug(self, instance_or_class, message):
126
         self._logger.debug(Logger.TPL.format(cls=self._txt(instance_or_class), msg=message))
126
         self._logger.debug(Logger.TPL.format(cls=self._txt(instance_or_class), msg=message))
127
 
127
 
128
-    def error(self, instance_or_class, message):
129
-        self._logger.error(Logger.TPL.format(cls=self._txt(instance_or_class), msg=message))
128
+    def error(self, instance_or_class, message, exc_info=0):
129
+        self._logger.error(Logger.TPL.format(cls=self._txt(instance_or_class), msg=message, exc_info=exc_info))
130
 
130
 
131
     def info(self, instance_or_class, message):
131
     def info(self, instance_or_class, message):
132
         self._logger.info(Logger.TPL.format(cls=self._txt(instance_or_class), msg=message))
132
         self._logger.info(Logger.TPL.format(cls=self._txt(instance_or_class), msg=message))

+ 7 - 2
tracim/tracim/lib/content.py View File

222
             result = result.filter(Content.type.in_(self.DISPLAYABLE_CONTENTS))
222
             result = result.filter(Content.type.in_(self.DISPLAYABLE_CONTENTS))
223
 
223
 
224
         if workspace:
224
         if workspace:
225
-            result = result.filter(Content.workspace_id==workspace.workspace_id)
225
+            result = result.filter(Content.workspace_id == workspace.workspace_id)
226
 
226
 
227
         # Security layer: if user provided, filter
227
         # Security layer: if user provided, filter
228
         # with user workspaces privileges
228
         # with user workspaces privileges
735
         assert content_type is not None# DYN_REMOVE
735
         assert content_type is not None# DYN_REMOVE
736
         assert isinstance(content_type, str) # DYN_REMOVE
736
         assert isinstance(content_type, str) # DYN_REMOVE
737
 
737
 
738
-        resultset = self._base_query(workspace).order_by(desc(Content.updated))
738
+        resultset = self._base_query(workspace) \
739
+            .filter(Content.workspace_id == Workspace.workspace_id) \
740
+            .filter(Workspace.is_deleted.is_(False)) \
741
+            .order_by(desc(Content.updated))
739
 
742
 
740
         if content_type!=ContentType.Any:
743
         if content_type!=ContentType.Any:
741
             resultset = resultset.filter(Content.type==content_type)
744
             resultset = resultset.filter(Content.type==content_type)
773
 
776
 
774
         not_read_revisions = self._revisions_base_query(workspace) \
777
         not_read_revisions = self._revisions_base_query(workspace) \
775
             .filter(~ContentRevisionRO.revision_id.in_(read_revision_ids)) \
778
             .filter(~ContentRevisionRO.revision_id.in_(read_revision_ids)) \
779
+            .filter(ContentRevisionRO.workspace_id == Workspace.workspace_id) \
780
+            .filter(Workspace.is_deleted.is_(False)) \
776
             .subquery()
781
             .subquery()
777
 
782
 
778
         not_read_content_ids_query = DBSession.query(
783
         not_read_content_ids_query = DBSession.query(

+ 43 - 2
tracim/tracim/lib/daemons.py View File

19
 from tracim.lib.exceptions import AlreadyRunningDaemon
19
 from tracim.lib.exceptions import AlreadyRunningDaemon
20
 
20
 
21
 from tracim.lib.utils import get_rq_queue
21
 from tracim.lib.utils import get_rq_queue
22
+from tracim.lib.email_fetcher import MailFetcher
22
 
23
 
23
 
24
 
24
 class DaemonsManager(object):
25
 class DaemonsManager(object):
151
         raise NotImplementedError()
152
         raise NotImplementedError()
152
 
153
 
153
 
154
 
155
+class MailFetcherDaemon(Daemon):
156
+    """
157
+    Thread containing a daemon who fetch new mail from a mailbox and
158
+    send http request to a tracim endpoint to handle them.
159
+    """
160
+
161
+    def __init__(self, *args, **kwargs) -> None:
162
+        super().__init__(*args, **kwargs)
163
+        self._fetcher = None  # type: MailFetcher
164
+        self.ok = True
165
+
166
+    def run(self) -> None:
167
+        from tracim.config.app_cfg import CFG
168
+        cfg = CFG.get_instance()
169
+        self._fetcher = MailFetcher(
170
+            host=cfg.EMAIL_REPLY_IMAP_SERVER,
171
+            port=cfg.EMAIL_REPLY_IMAP_PORT,
172
+            user=cfg.EMAIL_REPLY_IMAP_USER,
173
+            password=cfg.EMAIL_REPLY_IMAP_PASSWORD,
174
+            use_ssl=cfg.EMAIL_REPLY_IMAP_USE_SSL,
175
+            folder=cfg.EMAIL_REPLY_IMAP_FOLDER,
176
+            delay=cfg.EMAIL_REPLY_CHECK_HEARTBEAT,
177
+            # FIXME - G.M - 2017-11-15 - proper tracim url formatting
178
+            endpoint=cfg.WEBSITE_BASE_URL + "/events",
179
+            token=cfg.EMAIL_REPLY_TOKEN,
180
+            use_html_parsing=cfg.EMAIL_REPLY_USE_HTML_PARSING,
181
+            use_txt_parsing=cfg.EMAIL_REPLY_USE_TXT_PARSING,
182
+            lockfile_path=cfg.EMAIL_REPLY_LOCKFILE_PATH,
183
+        )
184
+        self._fetcher.run()
185
+
186
+    def stop(self) -> None:
187
+        if self._fetcher:
188
+            self._fetcher.stop()
189
+
190
+    def append_thread_callback(self, callback: collections.Callable) -> None:
191
+        logger.warning('MailFetcherDaemon not implement append_thread_calback')
192
+        pass
193
+
194
+
154
 class MailSenderDaemon(Daemon):
195
 class MailSenderDaemon(Daemon):
155
     # NOTE: use *args and **kwargs because parent __init__ use strange
196
     # NOTE: use *args and **kwargs because parent __init__ use strange
156
     # * parameter
197
     # * parameter
314
 from inspect import isfunction
355
 from inspect import isfunction
315
 import traceback
356
 import traceback
316
 
357
 
317
-from wsgidav.server.cherrypy import wsgiserver
318
-from wsgidav.server.cherrypy.wsgiserver.wsgiserver3 import CherryPyWSGIServer
358
+from cherrypy import wsgiserver
359
+from cherrypy.wsgiserver import CherryPyWSGIServer
319
 
360
 
320
 DEFAULT_CONFIG_FILE = "wsgidav.conf"
361
 DEFAULT_CONFIG_FILE = "wsgidav.conf"
321
 PYTHON_VERSION = "%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])
362
 PYTHON_VERSION = "%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])

+ 4 - 3
tracim/tracim/lib/email.py View File

4
 from email.message import Message
4
 from email.message import Message
5
 from email.mime.multipart import MIMEMultipart
5
 from email.mime.multipart import MIMEMultipart
6
 from email.mime.text import MIMEText
6
 from email.mime.text import MIMEText
7
+from email.utils import formataddr
7
 
8
 
8
 from mako.template import Template
9
 from mako.template import Template
9
 from tg.i18n import ugettext as _
10
 from tg.i18n import ugettext as _
156
             )
157
             )
157
         message = MIMEMultipart('alternative')
158
         message = MIMEMultipart('alternative')
158
         message['Subject'] = subject
159
         message['Subject'] = subject
159
-        message['From'] = '{0} <{1}>'.format(
160
+        message['From'] = formataddr((
160
             self._global_config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL,
161
             self._global_config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL,
161
             self._global_config.EMAIL_NOTIFICATION_FROM_EMAIL,
162
             self._global_config.EMAIL_NOTIFICATION_FROM_EMAIL,
162
-        )
163
-        message['To'] = user.email
163
+        ))
164
+        message['To'] = formataddr((user.get_display_name(), user.email))
164
 
165
 
165
         text_template_file_path = self._global_config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT  # nopep8
166
         text_template_file_path = self._global_config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT  # nopep8
166
         html_template_file_path = self._global_config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML  # nopep8
167
         html_template_file_path = self._global_config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML  # nopep8

+ 434 - 0
tracim/tracim/lib/email_fetcher.py View File

1
+# -*- coding: utf-8 -*-
2
+
3
+import time
4
+import imaplib
5
+import json
6
+import typing
7
+from email import message_from_bytes
8
+from email.header import decode_header
9
+from email.header import make_header
10
+from email.message import Message
11
+from email.utils import parseaddr
12
+
13
+import filelock
14
+import markdown
15
+import requests
16
+from email_reply_parser import EmailReplyParser
17
+from tracim.lib.base import logger
18
+from tracim.lib.email_processing.parser import ParsedHTMLMail
19
+from tracim.lib.email_processing.sanitizer import HtmlSanitizer
20
+
21
+TRACIM_SPECIAL_KEY_HEADER = 'X-Tracim-Key'
22
+CONTENT_TYPE_TEXT_PLAIN = 'text/plain'
23
+CONTENT_TYPE_TEXT_HTML = 'text/html'
24
+
25
+IMAP_SEEN_FLAG = '\\Seen'
26
+IMAP_CHECKED_FLAG = '\\Flagged'
27
+MAIL_FETCHER_FILELOCK_TIMEOUT = 10
28
+
29
+
30
+class MessageContainer(object):
31
+    def __init__(self, message: Message, uid: int) -> None:
32
+        self.message = message
33
+        self.uid = uid
34
+
35
+
36
+class DecodedMail(object):
37
+    def __init__(self, message: Message, uid: int=None) -> None:
38
+        self._message = message
39
+        self.uid = uid
40
+
41
+    def _decode_header(self, header_title: str) -> typing.Optional[str]:
42
+        # FIXME : Handle exception
43
+        if header_title in self._message:
44
+            return str(make_header(decode_header(self._message[header_title])))
45
+        else:
46
+            return None
47
+
48
+    def get_subject(self) -> typing.Optional[str]:
49
+        return self._decode_header('subject')
50
+
51
+    def get_from_address(self) -> str:
52
+        return parseaddr(self._message['From'])[1]
53
+
54
+    def get_to_address(self) -> str:
55
+        return parseaddr(self._message['To'])[1]
56
+
57
+    def get_first_ref(self) -> str:
58
+        return parseaddr(self._message['References'])[1]
59
+
60
+    def get_special_key(self) -> typing.Optional[str]:
61
+        return self._decode_header(TRACIM_SPECIAL_KEY_HEADER)
62
+
63
+    def get_body(
64
+            self,
65
+            use_html_parsing=True,
66
+            use_txt_parsing=True,
67
+    ) -> typing.Optional[str]:
68
+        body_part = self._get_mime_body_message()
69
+        body = None
70
+        if body_part:
71
+            charset = body_part.get_content_charset('iso-8859-1')
72
+            content_type = body_part.get_content_type()
73
+            if content_type == CONTENT_TYPE_TEXT_PLAIN:
74
+                txt_body = body_part.get_payload(decode=True).decode(
75
+                    charset)
76
+                if use_txt_parsing:
77
+                    txt_body = EmailReplyParser.parse_reply(txt_body)
78
+                html_body = markdown.markdown(txt_body)
79
+                body = HtmlSanitizer.sanitize(html_body)
80
+
81
+            elif content_type == CONTENT_TYPE_TEXT_HTML:
82
+                html_body = body_part.get_payload(decode=True).decode(
83
+                    charset)
84
+                if use_html_parsing:
85
+                    html_body = str(ParsedHTMLMail(html_body))
86
+                body = HtmlSanitizer.sanitize(html_body)
87
+
88
+        return body
89
+
90
+    def _get_mime_body_message(self) -> typing.Optional[Message]:
91
+        # TODO - G.M - 2017-11-16 - Use stdlib msg.get_body feature for py3.6+
92
+        part = None
93
+        # Check for html
94
+        for part in self._message.walk():
95
+            content_type = part.get_content_type()
96
+            content_dispo = str(part.get('Content-Disposition'))
97
+            if content_type == CONTENT_TYPE_TEXT_HTML \
98
+                    and 'attachment' not in content_dispo:
99
+                return part
100
+        # check for plain text
101
+        for part in self._message.walk():
102
+            content_type = part.get_content_type()
103
+            content_dispo = str(part.get('Content-Disposition'))
104
+            if content_type == CONTENT_TYPE_TEXT_PLAIN \
105
+                    and 'attachment' not in content_dispo:
106
+                return part
107
+        return part
108
+
109
+    def get_key(self) -> typing.Optional[str]:
110
+
111
+        """
112
+        key is the string contain in some mail header we need to retrieve.
113
+        First try checking special header, them check 'to' header
114
+        and finally check first(oldest) mail-id of 'references' header
115
+        """
116
+        first_ref = self.get_first_ref()
117
+        to_address = self.get_to_address()
118
+        special_key = self.get_special_key()
119
+
120
+        if special_key:
121
+            return special_key
122
+        if to_address:
123
+            return DecodedMail.find_key_from_mail_address(to_address)
124
+        if first_ref:
125
+            return DecodedMail.find_key_from_mail_address(first_ref)
126
+
127
+        return None
128
+
129
+    @classmethod
130
+    def find_key_from_mail_address(
131
+        cls,
132
+        mail_address: str,
133
+    ) -> typing.Optional[str]:
134
+        """ Parse mail_adress-like string
135
+        to retrieve key.
136
+
137
+        :param mail_address: user+key@something like string
138
+        :return: key
139
+        """
140
+        username = mail_address.split('@')[0]
141
+        username_data = username.split('+')
142
+        if len(username_data) == 2:
143
+            return username_data[1]
144
+        return None
145
+
146
+
147
+class MailFetcher(object):
148
+    def __init__(
149
+        self,
150
+        host: str,
151
+        port: str,
152
+        user: str,
153
+        password: str,
154
+        use_ssl: bool,
155
+        folder: str,
156
+        delay: int,
157
+        endpoint: str,
158
+        token: str,
159
+        use_html_parsing: bool,
160
+        use_txt_parsing: bool,
161
+        lockfile_path: str,
162
+    ) -> None:
163
+        """
164
+        Fetch mail from a mailbox folder through IMAP and add their content to
165
+        Tracim through http according to mail Headers.
166
+        Fetch is regular.
167
+        :param host: imap server hostname
168
+        :param port: imap connection port
169
+        :param user: user login of mailbox
170
+        :param password: user password of mailbox
171
+        :param use_ssl: use imap over ssl connection
172
+        :param folder: mail folder where new mail are fetched
173
+        :param delay: seconds to wait before fetching new mail again
174
+        :param endpoint: tracim http endpoint where decoded mail are send.
175
+        :param token: token to authenticate http connexion
176
+        :param use_html_parsing: parse html mail
177
+        :param use_txt_parsing: parse txt mail
178
+        """
179
+        self._connection = None
180
+        self.host = host
181
+        self.port = port
182
+        self.user = user
183
+        self.password = password
184
+        self.use_ssl = use_ssl
185
+        self.folder = folder
186
+        self.delay = delay
187
+        self.endpoint = endpoint
188
+        self.token = token
189
+        self.use_html_parsing = use_html_parsing
190
+        self.use_txt_parsing = use_txt_parsing
191
+        self.lock = filelock.FileLock(lockfile_path)
192
+        self._is_active = True
193
+
194
+    def run(self) -> None:
195
+        logger.info(self, 'Starting MailFetcher')
196
+        while self._is_active:
197
+            logger.debug(self, 'sleep for {}'.format(self.delay))
198
+            time.sleep(self.delay)
199
+            try:
200
+                self._connect()
201
+                with self.lock.acquire(
202
+                        timeout=MAIL_FETCHER_FILELOCK_TIMEOUT
203
+                ):
204
+                    messages = self._fetch()
205
+                cleaned_mails = [DecodedMail(m.message, m.uid)
206
+                                 for m in messages]
207
+                self._notify_tracim(cleaned_mails)
208
+                self._disconnect()
209
+            except filelock.Timeout as e:
210
+                log = 'Mail Fetcher Lock Timeout {}'
211
+                logger.warning(self, log.format(e.__str__()))
212
+            except Exception as e:
213
+                # TODO - G.M - 2017-11-23 - Identify possible exceptions
214
+                log = 'IMAP error: {}'
215
+                logger.warning(self, log.format(e.__str__()))
216
+
217
+    def stop(self) -> None:
218
+        self._is_active = False
219
+
220
+    def _connect(self) -> None:
221
+        # TODO - G.M - 2017-11-15 Verify connection/disconnection
222
+        # Are old connexion properly close this way ?
223
+        if self._connection:
224
+            logger.debug(self, 'Disconnect from IMAP')
225
+            self._disconnect()
226
+        # TODO - G.M - 2017-11-23 Support for predefined SSLContext ?
227
+        # without ssl_context param, tracim use default security configuration
228
+        # which is great in most case.
229
+        if self.use_ssl:
230
+            logger.debug(self, 'Connect IMAP {}:{} using SSL'.format(
231
+                self.host,
232
+                self.port,
233
+            ))
234
+            self._connection = imaplib.IMAP4_SSL(self.host, self.port)
235
+        else:
236
+            logger.debug(self, 'Connect IMAP {}:{}'.format(
237
+                self.host,
238
+                self.port,
239
+            ))
240
+            self._connection = imaplib.IMAP4(self.host, self.port)
241
+
242
+        try:
243
+            logger.debug(self, 'Login IMAP with login {}'.format(
244
+                self.user,
245
+            ))
246
+            self._connection.login(self.user, self.password)
247
+        except Exception as e:
248
+            log = 'Error during execution: {}'
249
+            logger.error(self, log.format(e.__str__()), exc_info=1)
250
+
251
+    def _disconnect(self) -> None:
252
+        if self._connection:
253
+            self._connection.close()
254
+            self._connection.logout()
255
+            self._connection = None
256
+
257
+    def _fetch(self) -> typing.List[MessageContainer]:
258
+        """
259
+        Get news message from mailbox
260
+        :return: list of new mails
261
+        """
262
+        messages = []
263
+        # select mailbox
264
+        logger.debug(self, 'Fetch messages from folder {}'.format(
265
+            self.folder,
266
+        ))
267
+        rv, data = self._connection.select(self.folder)
268
+        logger.debug(self, 'Response status {}'.format(
269
+            rv,
270
+        ))
271
+        if rv == 'OK':
272
+            # get mails
273
+            # TODO - G.M -  2017-11-15 Which files to select as new file ?
274
+            # Unseen file or All file from a directory (old one should be
275
+            #  moved/ deleted from mailbox during this process) ?
276
+            logger.debug(self, 'Fetch unseen messages')
277
+
278
+            rv, data = self._connection.search(None, "(UNSEEN)")
279
+            logger.debug(self, 'Response status {}'.format(
280
+                rv,
281
+            ))
282
+            if rv == 'OK':
283
+                # get mail content
284
+                logger.debug(self, 'Found {} unseen mails'.format(
285
+                    len(data[0].split()),
286
+                ))
287
+                for uid in data[0].split():
288
+                    # INFO - G.M - 2017-12-08 - Fetch BODY.PEEK[]
289
+                    # Retrieve all mail(body and header) but don't set mail
290
+                    # as seen because of PEEK
291
+                    # see rfc3501
292
+                    logger.debug(self, 'Fetch mail "{}"'.format(
293
+                        uid,
294
+                    ))
295
+                    rv, data = self._connection.fetch(uid, 'BODY.PEEK[]')
296
+                    logger.debug(self, 'Response status {}'.format(
297
+                        rv,
298
+                    ))
299
+                    if rv == 'OK':
300
+                        msg = message_from_bytes(data[0][1])
301
+                        msg_container = MessageContainer(msg, uid)
302
+                        messages.append(msg_container)
303
+                        self._set_flag(uid, IMAP_SEEN_FLAG)
304
+                    else:
305
+                        log = 'IMAP : Unable to get mail : {}'
306
+                        logger.error(self, log.format(str(rv)))
307
+            else:
308
+                log = 'IMAP : Unable to get unseen mail : {}'
309
+                logger.error(self, log.format(str(rv)))
310
+        else:
311
+            log = 'IMAP : Unable to open mailbox : {}'
312
+            logger.error(self, log.format(str(rv)))
313
+        return messages
314
+
315
+    def _notify_tracim(
316
+        self,
317
+        mails: typing.List[DecodedMail],
318
+    ) -> None:
319
+        """
320
+        Send http request to tracim endpoint
321
+        :param mails: list of mails to send
322
+        :return: unsended mails
323
+        """
324
+        logger.debug(self, 'Notify tracim about {} new responses'.format(
325
+            len(mails),
326
+        ))
327
+        unsended_mails = []
328
+        # TODO BS 20171124: Look around mail.get_from_address(), mail.get_key()
329
+        # , mail.get_body() etc ... for raise InvalidEmailError if missing
330
+        #  required informations (actually get_from_address raise IndexError
331
+        #  if no from address for example) and catch it here
332
+        while mails:
333
+            mail = mails.pop()
334
+            body =  mail.get_body(
335
+                use_html_parsing=self.use_html_parsing,
336
+                use_txt_parsing=self.use_txt_parsing,
337
+            )
338
+            from_address = mail.get_from_address()
339
+
340
+            # don't create element for 'empty' mail
341
+            if not body:
342
+                logger.warning(
343
+                    self,
344
+                    'Mail from {} has not valable content'.format(
345
+                        from_address
346
+                    ),
347
+                )
348
+                continue
349
+
350
+            msg = {'token': self.token,
351
+                   'user_mail': from_address,
352
+                   'content_id': mail.get_key(),
353
+                   'payload': {
354
+                       'content': body,
355
+                   }}
356
+            try:
357
+                logger.debug(
358
+                    self,
359
+                    'Contact API on {} with body {}'.format(
360
+                        self.endpoint,
361
+                        json.dumps(msg),
362
+                    ),
363
+                )
364
+                r = requests.post(self.endpoint, json=msg)
365
+                if r.status_code not in [200, 204]:
366
+                    details = r.json().get('msg')
367
+                    log = 'bad status code {} response when sending mail to tracim: {}'  # nopep8
368
+                    logger.error(self, log.format(
369
+                        str(r.status_code),
370
+                        details,
371
+                    ))
372
+                # Flag all correctly checked mail, unseen the others
373
+                if r.status_code in [200, 204, 400]:
374
+                    self._set_flag(mail.uid, IMAP_CHECKED_FLAG)
375
+                else:
376
+                    self._unset_flag(mail.uid, IMAP_SEEN_FLAG)
377
+            # TODO - G.M - Verify exception correctly works
378
+            except requests.exceptions.Timeout as e:
379
+                log = 'Timeout error to transmit fetched mail to tracim : {}'
380
+                logger.error(self, log.format(str(e)))
381
+                unsended_mails.append(mail)
382
+                self._unset_flag(mail.uid, IMAP_SEEN_FLAG)
383
+            except requests.exceptions.RequestException as e:
384
+                log = 'Fail to transmit fetched mail to tracim : {}'
385
+                logger.error(self, log.format(str(e)))
386
+                self._unset_flag(mail.uid, IMAP_SEEN_FLAG)
387
+
388
+    def _set_flag(
389
+            self,
390
+            uid: int,
391
+            flag: str,
392
+            ) -> None:
393
+        assert uid is not None
394
+
395
+        rv, data = self._connection.store(
396
+            uid,
397
+            '+FLAGS',
398
+            flag,
399
+        )
400
+        if rv == 'OK':
401
+            log = 'Message {uid} set as {flag}.'.format(
402
+                uid=uid,
403
+                flag=flag)
404
+            logger.debug(self, log)
405
+        else:
406
+            log = 'Can not set Message {uid} as {flag} : {rv}'.format(
407
+                uid=uid,
408
+                flag=flag,
409
+                rv=rv)
410
+            logger.error(self, log)
411
+
412
+    def _unset_flag(
413
+            self,
414
+            uid: int,
415
+            flag: str,
416
+            ) -> None:
417
+        assert uid is not None
418
+
419
+        rv, data = self._connection.store(
420
+            uid,
421
+            '-FLAGS',
422
+            flag,
423
+        )
424
+        if rv == 'OK':
425
+            log = 'Message {uid} unset as {flag}.'.format(
426
+                uid=uid,
427
+                flag=flag)
428
+            logger.debug(self, log)
429
+        else:
430
+            log = 'Can not unset Message {uid} as {flag} : {rv}'.format(
431
+                uid=uid,
432
+                flag=flag,
433
+                rv=rv)
434
+            logger.error(self, log)

+ 0 - 0
tracim/tracim/lib/email_processing/__init__.py View File


+ 211 - 0
tracim/tracim/lib/email_processing/checkers.py View File

1
+# -*- coding: utf-8 -*-
2
+import typing
3
+
4
+from bs4 import Tag, NavigableString
5
+
6
+
7
+class ProprietaryHTMLAttrValues(object):
8
+    """
9
+    This are all Proprietary (mail client specific) html attr value we need to
10
+    check Html Elements
11
+    """
12
+    # Gmail
13
+    Gmail_extras_class = 'gmail_extra'
14
+    Gmail_quote_class = 'gmail_quote'
15
+    Gmail_signature_class = 'gmail_signature'
16
+    # Thunderbird
17
+    Thunderbird_quote_prefix_class = 'moz-cite-prefix'
18
+    Thunderbird_signature_class = 'moz-signature'
19
+    # Outlook.com
20
+    Outlook_com_quote_id = 'divRplyFwdMsg'
21
+    Outlook_com_signature_id = 'Signature'
22
+    Outlook_com_wrapper_id = 'divtagdefaultwrapper'
23
+    # Yahoo
24
+    Yahoo_quote_class = 'yahoo_quoted'
25
+    # Roundcube
26
+    # INFO - G.M - 2017-11-29 - New tag
27
+    # see : https://github.com/roundcube/roundcubemail/issues/6049
28
+    Roundcube_quote_prefix_class = 'reply-intro'
29
+
30
+
31
+class HtmlChecker(object):
32
+
33
+    @classmethod
34
+    def _has_attr_value(
35
+            cls,
36
+            elem: typing.Union[Tag, NavigableString],
37
+            attribute_name: str,
38
+            attribute_value: str,
39
+    )-> bool:
40
+        """
41
+        Check if elem contains attribute named attribute_name with
42
+        attribute_value : example <a id="ident"> elem contain attribute
43
+        with id as attribute_name and ident as attribute_value.
44
+        Checking is not case_sensitive.
45
+
46
+        :param elem: Tag or String Html Element
47
+        :param attribute_name: Html attribute name
48
+        :param attribute_value: Html attribute value
49
+        :return: True only if Element contain this attribute.
50
+        """
51
+        if isinstance(elem, Tag) and attribute_name in elem.attrs:
52
+            # INFO - G.M - 2017-12-01 - attrs[value}] can be string or list
53
+            # use get_attribute_list to always check in a list
54
+            # see https://www.crummy.com/software/BeautifulSoup/bs4/doc/#multi-valued-attributes # nopep8
55
+            values_lower = [value.lower()
56
+                            for value
57
+                            in elem.get_attribute_list(attribute_name)]
58
+            return attribute_value.lower() in values_lower
59
+        return False
60
+
61
+
62
+class HtmlMailQuoteChecker(HtmlChecker):
63
+    """
64
+    Check if one HTML Element from Body Mail look-like a quote or not.
65
+    """
66
+    @classmethod
67
+    def is_quote(
68
+            cls,
69
+            elem: typing.Union[Tag, NavigableString]
70
+    ) -> bool:
71
+        return cls._is_standard_quote(elem) \
72
+               or cls._is_thunderbird_quote(elem) \
73
+               or cls._is_gmail_quote(elem) \
74
+               or cls._is_outlook_com_quote(elem) \
75
+               or cls._is_yahoo_quote(elem) \
76
+               or cls._is_roundcube_quote(elem)
77
+
78
+    @classmethod
79
+    def _is_standard_quote(
80
+            cls,
81
+            elem: typing.Union[Tag, NavigableString]
82
+    ) -> bool:
83
+        if isinstance(elem, Tag) \
84
+                and elem.name.lower() == 'blockquote':
85
+            return True
86
+        return False
87
+
88
+    @classmethod
89
+    def _is_thunderbird_quote(
90
+            cls,
91
+            elem: typing.Union[Tag, NavigableString]
92
+    ) -> bool:
93
+        return cls._has_attr_value(
94
+            elem,
95
+            'class',
96
+            ProprietaryHTMLAttrValues.Thunderbird_quote_prefix_class)
97
+
98
+    @classmethod
99
+    def _is_gmail_quote(
100
+            cls,
101
+            elem: typing.Union[Tag, NavigableString]
102
+    ) -> bool:
103
+        if cls._has_attr_value(
104
+                elem,
105
+                'class',
106
+                ProprietaryHTMLAttrValues.Gmail_extras_class):
107
+            for child in elem.children:
108
+                if cls._has_attr_value(
109
+                        child,
110
+                        'class',
111
+                        ProprietaryHTMLAttrValues.Gmail_quote_class):
112
+                    return True
113
+        return False
114
+
115
+    @classmethod
116
+    def _is_outlook_com_quote(
117
+        cls,
118
+        elem: typing.Union[Tag, NavigableString]
119
+    ) -> bool:
120
+        if cls._has_attr_value(
121
+                elem,
122
+                'id',
123
+                ProprietaryHTMLAttrValues.Outlook_com_quote_id):
124
+            return True
125
+        return False
126
+
127
+    @classmethod
128
+    def _is_yahoo_quote(
129
+            cls,
130
+            elem: typing.Union[Tag, NavigableString]
131
+    ) -> bool:
132
+        return cls._has_attr_value(
133
+            elem,
134
+            'class',
135
+            ProprietaryHTMLAttrValues.Yahoo_quote_class)
136
+
137
+    @classmethod
138
+    def _is_roundcube_quote(
139
+            cls,
140
+            elem: typing.Union[Tag, NavigableString]
141
+    ) -> bool:
142
+        return cls._has_attr_value(
143
+            elem,
144
+            'id',
145
+            ProprietaryHTMLAttrValues.Roundcube_quote_prefix_class)
146
+
147
+
148
+class HtmlMailSignatureChecker(HtmlChecker):
149
+    """
150
+    Check if one HTML Element from Body Mail look-like a signature or not.
151
+    """
152
+
153
+    @classmethod
154
+    def is_signature(
155
+            cls,
156
+            elem: typing.Union[Tag, NavigableString]
157
+    ) -> bool:
158
+        return cls._is_thunderbird_signature(elem) \
159
+               or cls._is_gmail_signature(elem) \
160
+               or cls._is_outlook_com_signature(elem)
161
+
162
+    @classmethod
163
+    def _is_thunderbird_signature(
164
+            cls,
165
+            elem: typing.Union[Tag, NavigableString]
166
+    ) -> bool:
167
+        return cls._has_attr_value(
168
+            elem,
169
+            'class',
170
+            ProprietaryHTMLAttrValues.Thunderbird_signature_class)
171
+
172
+    @classmethod
173
+    def _is_gmail_signature(
174
+            cls,
175
+            elem: typing.Union[Tag, NavigableString]
176
+    ) -> bool:
177
+        if cls._has_attr_value(
178
+                elem,
179
+                'class',
180
+                ProprietaryHTMLAttrValues.Gmail_signature_class):
181
+            return True
182
+        if cls._has_attr_value(
183
+                elem,
184
+                'class',
185
+                ProprietaryHTMLAttrValues.Gmail_extras_class):
186
+            for child in elem.children:
187
+                if cls._has_attr_value(
188
+                        child,
189
+                        'class',
190
+                        ProprietaryHTMLAttrValues.Gmail_signature_class):
191
+                    return True
192
+        if isinstance(elem, Tag) and elem.name.lower() == 'div':
193
+            for child in elem.children:
194
+                if cls._has_attr_value(
195
+                        child,
196
+                        'class',
197
+                        ProprietaryHTMLAttrValues.Gmail_signature_class):
198
+                    return True
199
+        return False
200
+
201
+    @classmethod
202
+    def _is_outlook_com_signature(
203
+            cls,
204
+            elem: typing.Union[Tag, NavigableString]
205
+    ) -> bool:
206
+        if cls._has_attr_value(
207
+                elem,
208
+                'id',
209
+                ProprietaryHTMLAttrValues.Outlook_com_signature_id):
210
+            return True
211
+        return False

+ 116 - 0
tracim/tracim/lib/email_processing/models.py View File

1
+from bs4 import BeautifulSoup
2
+
3
+# -*- coding: utf-8 -*-
4
+
5
+
6
+class BodyMailPartType(object):
7
+    Signature = 'sign'
8
+    Main = 'main'
9
+    Quote = 'quote'
10
+
11
+
12
+class BodyMailPart(object):
13
+    def __init__(
14
+            self,
15
+            text: str,
16
+            part_type: str
17
+    )-> None:
18
+        self.text = text
19
+        self.part_type = part_type
20
+
21
+
22
+class BodyMailParts(object):
23
+    """
24
+    Data Structure to Distinct part of a Mail body into a "list" of BodyMailPart
25
+    When 2 similar BodyMailPart (same part_type) are added one after the other,
26
+    it doesn't create a new Part, it just merge those elements into one.
27
+    It should always have only one Signature type part, normally
28
+    at the end of the body.
29
+    This object doesn't provide other set method than append() in order to
30
+    preserve object coherence.
31
+    """
32
+    def __init__(self) -> None:
33
+        self._list = []  # type; List[BodyMailPart]
34
+        # INFO - G.M -
35
+        # automatically merge new value with last item if true, without any
36
+        # part_type check, same type as the older one, useful when some tag
37
+        # say "all elem after me is Signature"
38
+        self.follow = False
39
+
40
+    def __len__(self) -> int:
41
+        return len(self._list)
42
+
43
+    def __getitem__(self, index) -> BodyMailPart:
44
+        return self._list[index]
45
+
46
+    def __delitem__(self, index) -> None:
47
+        del self._list[index]
48
+        # FIXME - G.M - 2017-11-27 - Preserve BodyMailParts consistence
49
+        # check elem after and before index and merge them if necessary.
50
+
51
+    def append(self, value) -> None:
52
+        BodyMailParts._check_value(value)
53
+        self._append(value)
54
+
55
+    def _append(self, value, follow=None) -> None:
56
+        if follow is None:
57
+            follow = self.follow
58
+
59
+        if len(self._list) < 1:
60
+            self._list.append(value)
61
+        else:
62
+            if self._list[-1].part_type == value.part_type or follow:
63
+                self._list[-1].text += value.text
64
+            else:
65
+                self._list.append(value)
66
+
67
+    @classmethod
68
+    def _check_value(cls, value) -> None:
69
+        if not isinstance(value, BodyMailPart):
70
+            raise TypeError()
71
+
72
+    def drop_part_type(self, part_type: str) -> None:
73
+        """
74
+        Drop all elem of one part_type
75
+        :param part_type: part_type to completely remove
76
+        :return: None
77
+        """
78
+        new_list = [x for x in self._list if x.part_type != part_type]
79
+        self._list = []
80
+
81
+        # INFO - G.M - 2017-11-27 - use append() to have a consistent list
82
+        for elem in new_list:
83
+            self._append(elem, follow=False)
84
+
85
+    def get_nb_part_type(self, part_type: str) -> int:
86
+        """
87
+        Get number of elements of one part_type
88
+        :param part_type: part_type to check
89
+        :return: number of part_type elements
90
+        """
91
+        count = 0
92
+        for elem in self._list:
93
+            if elem.part_type == part_type:
94
+                count += 1
95
+        return count
96
+
97
+    def __str__(self) -> str:
98
+        s_mail = ''
99
+        for elem in self._list:
100
+            s_mail += elem.text
101
+        return str(s_mail)
102
+
103
+
104
+class HtmlBodyMailParts(BodyMailParts):
105
+
106
+    def append(self, value):
107
+        # INFO - G.M - 2017-12-01 - Override part_type is elem has no content.
108
+        # Choose last elem part_type instead of the proposed one.
109
+        if len(self._list) > 0:
110
+            txt = BeautifulSoup(value.text, 'html.parser').get_text()
111
+            txt = txt.replace('\n', '').strip()
112
+            img = BeautifulSoup(value.text, 'html.parser').find('img')
113
+            if not txt and not img:
114
+                value.part_type = self._list[-1].part_type
115
+        BodyMailParts._check_value(value)
116
+        BodyMailParts._append(self, value)

+ 128 - 0
tracim/tracim/lib/email_processing/parser.py View File

1
+# -*- coding: utf-8 -*-
2
+from bs4 import BeautifulSoup
3
+
4
+from tracim.lib.email_processing.checkers import ProprietaryHTMLAttrValues
5
+from tracim.lib.email_processing.checkers import HtmlMailQuoteChecker
6
+from tracim.lib.email_processing.checkers import HtmlMailSignatureChecker
7
+from tracim.lib.email_processing.models import BodyMailPartType
8
+from tracim.lib.email_processing.models import BodyMailPart
9
+from tracim.lib.email_processing.models import HtmlBodyMailParts
10
+
11
+
12
+class PreSanitizeConfig(object):
13
+    """
14
+    To avoid problems, html need to be sanitize a bit during parsing to distinct
15
+    Main,Quote and Signature elements
16
+    """
17
+    meta_tag = ['body', 'div']
18
+
19
+
20
+class ParsedHTMLMail(object):
21
+    """
22
+    Parse HTML Mail depending of some rules.
23
+    Distinct part of html mail body using BodyMailParts object and
24
+    process differents rules using HtmlChecker(s)
25
+    """
26
+
27
+    def __init__(self, html_body: str):
28
+        self.src_html_body = html_body
29
+
30
+    def __str__(self):
31
+        return str(self._parse_mail())
32
+
33
+    def get_elements(self) -> HtmlBodyMailParts:
34
+        tree = self._get_proper_main_body_tree()
35
+        return self._distinct_elements(tree)
36
+
37
+    def _parse_mail(self) -> HtmlBodyMailParts:
38
+        elements = self.get_elements()
39
+        elements = self._process_elements(elements)
40
+        return elements
41
+
42
+    def _get_proper_main_body_tree(self) -> BeautifulSoup:
43
+        """
44
+        Get html body tree without some kind of wrapper.
45
+        We need to have text, quote and signature parts at the same tree level
46
+        """
47
+        tree = BeautifulSoup(self.src_html_body, 'html.parser')
48
+
49
+        # Only parse body part of html if available
50
+        subtree = tree.find('body')
51
+        if subtree:
52
+            tree = BeautifulSoup(str(subtree), 'html.parser')
53
+
54
+        # if some kind of "meta_div", unwrap it
55
+        while len(tree.findAll(recursive=None)) == 1 and \
56
+                tree.find().name.lower() in PreSanitizeConfig.meta_tag:
57
+            tree.find().unwrap()
58
+
59
+        for tag in tree.findAll():
60
+            # HACK - G.M - 2017-11-28 - Unwrap outlook.com mail
61
+            # if Text -> Signature -> Quote Mail
62
+            # Text and signature are wrapped into divtagdefaultwrapper
63
+            if tag.attrs.get('id'):
64
+                if ProprietaryHTMLAttrValues.Outlook_com_wrapper_id\
65
+                        in tag.attrs['id']:
66
+                    tag.unwrap()
67
+        return tree
68
+
69
+    @classmethod
70
+    def _distinct_elements(cls, tree: BeautifulSoup) -> HtmlBodyMailParts:
71
+        parts = HtmlBodyMailParts()
72
+        for elem in list(tree):
73
+            part_txt = str(elem)
74
+            part_type = BodyMailPartType.Main
75
+
76
+            if HtmlMailQuoteChecker.is_quote(elem):
77
+                part_type = BodyMailPartType.Quote
78
+            elif HtmlMailSignatureChecker.is_signature(elem):
79
+                part_type = BodyMailPartType.Signature
80
+
81
+            part = BodyMailPart(part_txt, part_type)
82
+            parts.append(part)
83
+            # INFO - G.M - 2017-11-28 - Outlook.com special case
84
+            # all after quote tag is quote
85
+            if HtmlMailQuoteChecker._is_outlook_com_quote(elem):
86
+                parts.follow = True
87
+        return parts
88
+
89
+    @classmethod
90
+    def _process_elements(
91
+            cls,
92
+            elements: HtmlBodyMailParts,
93
+    ) -> HtmlBodyMailParts:
94
+        if len(elements) >= 2:
95
+            # Case 1 and 2, only one main and one quote
96
+            if elements.get_nb_part_type('main') == 1 and \
97
+                            elements.get_nb_part_type('quote') == 1:
98
+                # Case 1 : Main first
99
+                if elements[0].part_type == BodyMailPartType.Main:
100
+                    cls._process_main_first_case(elements)
101
+                # Case 2 : Quote first
102
+                if elements[0].part_type == BodyMailPartType.Quote:
103
+                    cls._process_quote_first_case(elements)
104
+            else:
105
+                # Case 3 : Multiple quotes and/or main
106
+                cls._process_multiples_elems_case(elements)
107
+        else:
108
+            cls._process_default_case(elements)
109
+            # default case (only one element or empty list)
110
+        return elements
111
+
112
+    @classmethod
113
+    def _process_quote_first_case(cls, elements: HtmlBodyMailParts) -> None:
114
+        elements.drop_part_type(BodyMailPartType.Signature)
115
+
116
+    @classmethod
117
+    def _process_main_first_case(cls, elements: HtmlBodyMailParts) -> None:
118
+        elements.drop_part_type(BodyMailPartType.Quote)
119
+        elements.drop_part_type(BodyMailPartType.Signature)
120
+
121
+    @classmethod
122
+    def _process_multiples_elems_case(cls, elements: HtmlBodyMailParts) -> None:
123
+        elements.drop_part_type(BodyMailPartType.Signature)
124
+
125
+    @classmethod
126
+    def _process_default_case(cls, elements: HtmlBodyMailParts) -> None:
127
+        elements.drop_part_type(BodyMailPartType.Quote)
128
+        elements.drop_part_type(BodyMailPartType.Signature)

+ 66 - 0
tracim/tracim/lib/email_processing/sanitizer.py View File

1
+import typing
2
+from bs4 import BeautifulSoup, Tag
3
+from tracim.lib.email_processing.sanitizer_config.attrs_whitelist import ATTRS_WHITELIST  # nopep8
4
+from tracim.lib.email_processing.sanitizer_config.class_blacklist import CLASS_BLACKLIST  # nopep8
5
+from tracim.lib.email_processing.sanitizer_config.id_blacklist import ID_BLACKLIST  # nopep8
6
+from tracim.lib.email_processing.sanitizer_config.tag_blacklist import TAG_BLACKLIST  # nopep8
7
+from tracim.lib.email_processing.sanitizer_config.tag_whitelist import TAG_WHITELIST  # nopep8
8
+
9
+class HtmlSanitizerConfig(object):
10
+    # whitelist : keep tag and content
11
+    Tag_whitelist = TAG_WHITELIST
12
+    Attrs_whitelist = ATTRS_WHITELIST
13
+    # blacklist : remove content
14
+    Tag_blacklist = TAG_BLACKLIST
15
+    Class_blacklist = CLASS_BLACKLIST
16
+    Id_blacklist = ID_BLACKLIST
17
+
18
+class HtmlSanitizer(object):
19
+    """
20
+    Sanitize Html Rules :
21
+    - Tag :
22
+      - Remove Tag_blacklist tag
23
+      - Keep Tag_whitelist tag
24
+      - Unwrap others tags
25
+    - Attrs :
26
+      - Remove non-whitelisted attributes
27
+    """
28
+
29
+    @classmethod
30
+    def sanitize(cls, html_body: str) -> typing.Optional[str]:
31
+        soup = BeautifulSoup(html_body, 'html.parser')
32
+        for tag in soup.findAll():
33
+            if cls._tag_to_extract(tag):
34
+                tag.extract()
35
+            elif tag.name.lower() in HtmlSanitizerConfig.Tag_whitelist:
36
+                attrs = dict(tag.attrs)
37
+                for attr in attrs:
38
+                    if attr not in HtmlSanitizerConfig.Attrs_whitelist:
39
+                        del tag.attrs[attr]
40
+            else:
41
+                tag.unwrap()
42
+
43
+        if cls._is_content_empty(soup):
44
+            return None
45
+        else:
46
+            return str(soup)
47
+
48
+    @classmethod
49
+    def _is_content_empty(cls, soup):
50
+        img = soup.find('img')
51
+        txt = soup.get_text().replace('\n', '').strip()
52
+        return (not img and not txt)
53
+
54
+    @classmethod
55
+    def _tag_to_extract(cls, tag: Tag) -> bool:
56
+        if tag.name.lower() in HtmlSanitizerConfig.Tag_blacklist:
57
+            return True
58
+        if 'class' in tag.attrs:
59
+            for elem in HtmlSanitizerConfig.Class_blacklist:
60
+                if elem in tag.attrs['class']:
61
+                    return True
62
+        if 'id' in tag.attrs:
63
+            for elem in HtmlSanitizerConfig.Id_blacklist:
64
+                if elem in tag.attrs['id']:
65
+                    return True
66
+        return False

+ 1 - 0
tracim/tracim/lib/email_processing/sanitizer_config/attrs_whitelist.py View File

1
+ATTRS_WHITELIST = ['href']

+ 1 - 0
tracim/tracim/lib/email_processing/sanitizer_config/class_blacklist.py View File

1
+CLASS_BLACKLIST =  []

+ 1 - 0
tracim/tracim/lib/email_processing/sanitizer_config/id_blacklist.py View File

1
+ID_BLACKLIST = []

+ 1 - 0
tracim/tracim/lib/email_processing/sanitizer_config/tag_blacklist.py View File

1
+TAG_BLACKLIST = ['script', 'style']

+ 16 - 0
tracim/tracim/lib/email_processing/sanitizer_config/tag_whitelist.py View File

1
+TAG_WHITELIST = [
2
+    'b', 'blockquote', 'br',
3
+    'caption', 'cite', 'code',
4
+    'dd', 'del', 'dfn', 'dl', 'dt',
5
+    'em',
6
+    'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
7
+    'i', 'img', 'ins',
8
+    'li',
9
+    'mark',
10
+    'ol',
11
+    'p', 'pre',
12
+    'q',
13
+    'samp', 'small', 'strong', 'sub', 'sup',
14
+    'table', 'tbody', 'td', 'tfoot', 'thead', 'tr',
15
+    'u', 'ul'
16
+]

+ 22 - 6
tracim/tracim/lib/notifications.py View File

5
 from email.header import Header
5
 from email.header import Header
6
 from email.mime.multipart import MIMEMultipart
6
 from email.mime.multipart import MIMEMultipart
7
 from email.mime.text import MIMEText
7
 from email.mime.text import MIMEText
8
+from email.utils import formataddr
8
 
9
 
9
 from lxml.html.diff import htmldiff
10
 from lxml.html.diff import htmldiff
10
 
11
 
207
         else:
208
         else:
208
             email_address = email_template.replace('{user_id}', '0')
209
             email_address = email_template.replace('{user_id}', '0')
209
 
210
 
210
-        return '{label} <{email_address}>'.format(
211
-            label = Header(mail_sender_name).encode(),
212
-            email_address = email_address
213
-        )
211
+        return formataddr((mail_sender_name, email_address))
214
 
212
 
215
     @staticmethod
213
     @staticmethod
216
     def log_notification(
214
     def log_notification(
269
 
267
 
270
         for role in notifiable_roles:
268
         for role in notifiable_roles:
271
             logger.info(self, 'Sending email to {}'.format(role.user.email))
269
             logger.info(self, 'Sending email to {}'.format(role.user.email))
272
-            to_addr = '{name} <{email}>'.format(name=role.user.display_name, email=role.user.email)
270
+            to_addr = formataddr((role.user.display_name, role.user.email))
271
+            #
272
+            # INFO - G.M - 2017-11-15 - set content_id in header to permit reply
273
+            # references can have multiple values, but only one in this case.
274
+            replyto_addr = self._global_config.EMAIL_NOTIFICATION_REPLY_TO_EMAIL.replace( # nopep8
275
+                '{content_id}',str(content.content_id)
276
+            )
273
 
277
 
278
+            reference_addr = self._global_config.EMAIL_NOTIFICATION_REFERENCES_EMAIL.replace( #nopep8
279
+                '{content_id}',str(content.content_id)
280
+             )
274
             #
281
             #
275
             #  INFO - D.A. - 2014-11-06
282
             #  INFO - D.A. - 2014-11-06
276
             # We do not use .format() here because the subject defined in the .ini file
283
             # We do not use .format() here because the subject defined in the .ini file
282
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
289
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
283
             subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
290
             subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
284
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
291
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
292
+            reply_to_label = l_('{username} & all members of {workspace}').format(
293
+                username=user.display_name,
294
+                workspace=main_content.workspace.label)
285
 
295
 
286
             message = MIMEMultipart('alternative')
296
             message = MIMEMultipart('alternative')
287
             message['Subject'] = subject
297
             message['Subject'] = subject
288
             message['From'] = self._get_sender(user)
298
             message['From'] = self._get_sender(user)
289
             message['To'] = to_addr
299
             message['To'] = to_addr
290
-
300
+            message['Reply-to'] = formataddr((reply_to_label, replyto_addr))
301
+            # INFO - G.M - 2017-11-15
302
+            # References can theorically have label, but in pratice, references
303
+            # contains only message_id from parents post in thread.
304
+            # To link this email to a content we create a virtual parent
305
+            # in reference who contain the content_id.
306
+            message['References'] = formataddr(('',reference_addr))
291
             body_text = self._build_email_body(self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
307
             body_text = self._build_email_body(self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
292
 
308
 
293
 
309
 

+ 10 - 4
tracim/tracim/lib/utils.py View File

3
 import time
3
 import time
4
 import signal
4
 import signal
5
 
5
 
6
+import tg
6
 from tg import config
7
 from tg import config
7
 from tg import require
8
 from tg import require
8
 from tg import response
9
 from tg import response
150
     :return: lazyfied string or string
151
     :return: lazyfied string or string
151
     """
152
     """
152
     try:
153
     try:
153
-        # Test if context is available,
154
+        # Test if tg.translator is defined
155
+        #
154
         # cf. https://github.com/tracim/tracim/issues/173
156
         # cf. https://github.com/tracim/tracim/issues/173
155
-        context = StackedObjectProxy(name="context")
156
-        context.translator
157
+        #
158
+        # HACK - 2017-11-03 - D.A
159
+        # Replace context proxyfied by direct access to gettext function
160
+        # which is not setup in case the tg2 context is not initialized
161
+        tg.translator.gettext  # raises a TypeError exception if context not set
157
         return ugettext(text)
162
         return ugettext(text)
158
-    except TypeError:
163
+    except TypeError as e:
164
+        logger.debug(_lazy_ugettext, 'TG2 context not available for translation. TypeError: {}'.format(e))
159
         return text
165
         return text
160
 
166
 
161
 lazy_ugettext = lazify(_lazy_ugettext)
167
 lazy_ugettext = lazify(_lazy_ugettext)

+ 6 - 4
tracim/tracim/lib/webdav/design.py View File

173
                    )
173
                    )
174
     histHTML += '</table>'
174
     histHTML += '</table>'
175
 
175
 
176
-    file = '''
176
+    page = '''
177
 <html>
177
 <html>
178
 <head>
178
 <head>
179
+	<meta charset="utf-8" />
179
 	<title>%s</title>
180
 	<title>%s</title>
180
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
181
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
181
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
182
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css">
233
                content_revision.description,
234
                content_revision.description,
234
                histHTML)
235
                histHTML)
235
 
236
 
236
-    return file
237
+    return page
237
 
238
 
238
 def designThread(content: data.Content, content_revision: data.ContentRevisionRO, comments) -> str:
239
 def designThread(content: data.Content, content_revision: data.ContentRevisionRO, comments) -> str:
239
         hist = content.get_history()
240
         hist = content.get_history()
291
                                # t.ref_object.label) if t.type.id in ['revision', 'creation', 'edition'] else '')
292
                                # t.ref_object.label) if t.type.id in ['revision', 'creation', 'edition'] else '')
292
                            )
293
                            )
293
 
294
 
294
-        page = '''
295
+        thread = '''
295
 <html>
296
 <html>
296
 <head>
297
 <head>
298
+	<meta charset="utf-8" />
297
 	<title>%s</title>
299
 	<title>%s</title>
298
 	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
300
 	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
299
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
301
 	<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
379
                content_revision.description,
381
                content_revision.description,
380
                disc)
382
                disc)
381
 
383
 
382
-        return page
384
+        return thread

+ 2 - 2
tracim/tracim/lib/workspace.py View File

34
 
34
 
35
         return DBSession.query(Workspace).\
35
         return DBSession.query(Workspace).\
36
             join(Workspace.roles).\
36
             join(Workspace.roles).\
37
-            filter(UserRoleInWorkspace.user_id==self._user.user_id).\
38
-            filter(Workspace.is_deleted==False)
37
+            filter(UserRoleInWorkspace.user_id == self._user.user_id).\
38
+            filter(Workspace.is_deleted == False)
39
 
39
 
40
     def create_workspace(
40
     def create_workspace(
41
             self,
41
             self,

+ 10 - 0
tracim/tracim/model/auth.py View File

280
         from tracim.model.data import UserRoleInWorkspace
280
         from tracim.model.data import UserRoleInWorkspace
281
         return UserRoleInWorkspace.NOT_APPLICABLE
281
         return UserRoleInWorkspace.NOT_APPLICABLE
282
 
282
 
283
+    def get_active_roles(self) -> ['UserRoleInWorkspace']:
284
+        """
285
+        :return: list of roles of the user for all not-deleted workspaces
286
+        """
287
+        roles = []
288
+        for role in self.roles:
289
+            if not role.workspace.is_deleted:
290
+                roles.append(role)
291
+        return roles
292
+
283
     def ensure_auth_token(self) -> None:
293
     def ensure_auth_token(self) -> None:
284
         """
294
         """
285
         Create auth_token if None, regenerate auth_token if too much old.
295
         Create auth_token if None, regenerate auth_token if too much old.

+ 3 - 13
tracim/tracim/model/data.py View File

1111
         self._properties = json.dumps(properties_struct)
1111
         self._properties = json.dumps(properties_struct)
1112
         ContentChecker.check_properties(self)
1112
         ContentChecker.check_properties(self)
1113
 
1113
 
1114
-    @property
1115
-    def clean_revisions(self):
1116
-        """
1117
-        This property return revisions with really only one of each revisions:
1118
-        Actually, .revisions list give duplicated last revision,
1119
-        see https://github.com/tracim/tracim/issues/126
1120
-        :return: list of revisions
1121
-        """
1122
-        return list(set(self.revisions))
1123
-
1124
     def created_as_delta(self, delta_from_datetime:datetime=None):
1114
     def created_as_delta(self, delta_from_datetime:datetime=None):
1125
         if not delta_from_datetime:
1115
         if not delta_from_datetime:
1126
             delta_from_datetime = datetime.utcnow()
1116
             delta_from_datetime = datetime.utcnow()
1213
         return last_comment
1203
         return last_comment
1214
 
1204
 
1215
     def get_previous_revision(self) -> 'ContentRevisionRO':
1205
     def get_previous_revision(self) -> 'ContentRevisionRO':
1216
-        rev_ids = [revision.revision_id for revision in self.clean_revisions]
1206
+        rev_ids = [revision.revision_id for revision in self.revisions]
1217
         rev_ids.sort()
1207
         rev_ids.sort()
1218
 
1208
 
1219
         if len(rev_ids)>=2:
1209
         if len(rev_ids)>=2:
1220
             revision_rev_id = rev_ids[-2]
1210
             revision_rev_id = rev_ids[-2]
1221
 
1211
 
1222
-            for revision in self.clean_revisions:
1212
+            for revision in self.revisions:
1223
                 if revision.revision_id == revision_rev_id:
1213
                 if revision.revision_id == revision_rev_id:
1224
                     return revision
1214
                     return revision
1225
 
1215
 
1248
         events = []
1238
         events = []
1249
         for comment in self.get_comments():
1239
         for comment in self.get_comments():
1250
             events.append(VirtualEvent.create_from_content(comment))
1240
             events.append(VirtualEvent.create_from_content(comment))
1251
-        for revision in self.clean_revisions:
1241
+        for revision in self.revisions:
1252
             events.append(VirtualEvent.create_from_content_revision(revision))
1242
             events.append(VirtualEvent.create_from_content_revision(revision))
1253
 
1243
 
1254
         sorted_events = sorted(events,
1244
         sorted_events = sorted(events,

+ 2 - 1
tracim/tracim/model/serializers.py View File

914
     result['id'] = user.user_id
914
     result['id'] = user.user_id
915
     result['name'] = user.get_display_name()
915
     result['name'] = user.get_display_name()
916
     result['email'] = user.email
916
     result['email'] = user.email
917
-    result['roles'] = context.toDict(user.roles)
917
+    result['roles'] = context.toDict(user.get_active_roles())
918
     result['enabled'] = user.is_active
918
     result['enabled'] = user.is_active
919
     result['profile'] = user.profile
919
     result['profile'] = user.profile
920
     result['calendar_url'] = user.calendar_url
920
     result['calendar_url'] = user.calendar_url
996
     result = DictLikeClass()
996
     result = DictLikeClass()
997
     result['id'] = workspace.workspace_id
997
     result['id'] = workspace.workspace_id
998
     result['name'] = workspace.label
998
     result['name'] = workspace.label
999
+    result['is_deleted'] = workspace.is_deleted
999
 
1000
 
1000
     return result
1001
     return result
1001
 
1002
 

+ 1 - 1
tracim/tracim/templates/admin/user_getall.mak View File

17
 <%def name="TITLE_ROW()">
17
 <%def name="TITLE_ROW()">
18
     <div class="row-fluid">
18
     <div class="row-fluid">
19
         <div>
19
         <div>
20
-            ${ROW.TITLE_ROW(_('Users'), 'fa-user', '', 't-user-color', _('manage users and associated workspaces'))}
20
+            ${ROW.TITLE_ROW(_('Users'), 'fa-user', '', 't-user-color', _('Manage users and associated workspaces'))}
21
         </div>
21
         </div>
22
     </div>
22
     </div>
23
 </%def>
23
 </%def>

+ 1 - 1
tracim/tracim/templates/admin/workspace_getall.mak View File

14
 </%def>
14
 </%def>
15
 
15
 
16
 <%def name="TITLE_ROW()">
16
 <%def name="TITLE_ROW()">
17
-    ${ROW.TITLE_ROW(_('Workspaces'), 'fa-bank', '', 't-user-color', _('manage workspaces and subscribed users'))}
17
+    ${ROW.TITLE_ROW(_('Workspaces'), 'fa-bank', '', 't-user-color', _('Manage workspaces and subscribed users'))}
18
 </%def>
18
 </%def>
19
 
19
 
20
 <div class="workspace__wrapper">
20
 <div class="workspace__wrapper">

+ 1 - 1
tracim/tracim/templates/file/edit.mak View File

14
                 <input name="label" type="text" class="form-control" id="file-title" placeholder="${_('Title')}" value="${file.label}">
14
                 <input name="label" type="text" class="form-control" id="file-title" placeholder="${_('Title')}" value="${file.label}">
15
             </div>
15
             </div>
16
             <div class="form-group">
16
             <div class="form-group">
17
-                <label for="file-content">${_('Content')}</label>
17
+                <label for="file-content">${_('Description')}</label>
18
                 <textarea id="file-content-textarea" name="comment" class="form-control pod-rich-textarea" id="file-content" placeholder="${_('Write here the file content')}">${file.content}</textarea>
18
                 <textarea id="file-content-textarea" name="comment" class="form-control pod-rich-textarea" id="file-content" placeholder="${_('Write here the file content')}">${file.content}</textarea>
19
             </div>
19
             </div>
20
         </div>
20
         </div>

+ 3 - 3
tracim/tracim/templates/file/getone.mak View File

66
     <div class="alert alert-info" role="alert">
66
     <div class="alert alert-info" role="alert">
67
         <p>
67
         <p>
68
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
68
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
69
-            ${_('Vous consultez <b>une version archivée</b> de la page courante.')|n}
69
+            ${_('You are looking at an <b>archived file</b>.')|n}
70
         </p>
70
         </p>
71
     </div>
71
     </div>
72
     % elif (result.file.is_deleted) :
72
     % elif (result.file.is_deleted) :
73
     <div class="alert alert-info" role="alert">
73
     <div class="alert alert-info" role="alert">
74
         <p>
74
         <p>
75
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
75
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
76
-            ${_('Vous consultez <b>une version supprimée</b> de la page courante.')|n}
76
+            ${_('You are looking at a <b>deleted file</b>.')|n}
77
         </p>
77
         </p>
78
     </div>
78
     </div>
79
     % endif
79
     % endif
124
                     selectedRevision: '${result.file.selected_revision}',
124
                     selectedRevision: '${result.file.selected_revision}',
125
                     weight: '${h.user_friendly_file_size(result.file.file.size)}',
125
                     weight: '${h.user_friendly_file_size(result.file.file.size)}',
126
                     height: '300',
126
                     height: '300',
127
-                    modifiedAt: '${h.format_short(created_localized)|n}',
127
+                    modifiedAt: '${h.format_short(updated_localized)|n}',
128
                     owner: '${result.file.owner.name}',
128
                     owner: '${result.file.owner.name}',
129
                     sourceLink: '${download_url}',
129
                     sourceLink: '${download_url}',
130
                     pdfAvailable: ${pdf_available}
130
                     pdfAvailable: ${pdf_available}

+ 2 - 2
tracim/tracim/templates/folder/getone.mak View File

64
     <div class="alert alert-info" role="alert">
64
     <div class="alert alert-info" role="alert">
65
         <p>
65
         <p>
66
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
66
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
67
-            ${_('Vous consultez <b>une version archivée</b> de la page courante.')|n}
67
+            ${_('You are looking at an <b>archived folder</b>.')|n}
68
         </p>
68
         </p>
69
     </div>
69
     </div>
70
     % elif (result.folder.is_deleted) :
70
     % elif (result.folder.is_deleted) :
71
     <div class="alert alert-info" role="alert">
71
     <div class="alert alert-info" role="alert">
72
         <p>
72
         <p>
73
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
73
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
74
-            ${_('Vous consultez <b>une version supprimée</b> de la page courante.')|n}
74
+            ${_('You are looking at a <b>deleted folder</b>.')|n}
75
         </p>
75
         </p>
76
     </div>
76
     </div>
77
     % endif
77
     % endif

+ 3 - 1
tracim/tracim/templates/home.mak View File

137
                             </tr>
137
                             </tr>
138
                         </thead>
138
                         </thead>
139
                         % for role in fake_api.current_user.roles:
139
                         % for role in fake_api.current_user.roles:
140
-                            ${TABLE_ROW.USER_ROLE_IN_WORKSPACE(fake_api.current_user, role, show_id=False, enable_link='/user/me/workspaces/{workspace}/enable_notifications?next_url=/home', disable_link='/user/me/workspaces/{workspace}/disable_notifications?next_url=/home', base_link='/workspaces/{workspace}')}
140
+                            % if not role.workspace.is_deleted:
141
+                                ${TABLE_ROW.USER_ROLE_IN_WORKSPACE(fake_api.current_user, role, show_id=False, enable_link='/user/me/workspaces/{workspace}/enable_notifications?next_url=/home', disable_link='/user/me/workspaces/{workspace}/disable_notifications?next_url=/home', base_link='/workspaces/{workspace}')}
142
+                            % endif
141
                         % endfor
143
                         % endfor
142
                     </table>
144
                     </table>
143
                 % endif
145
                 % endif

+ 2 - 2
tracim/tracim/templates/page/getone.mak View File

64
     <div class="alert alert-info" role="alert">
64
     <div class="alert alert-info" role="alert">
65
         <p>
65
         <p>
66
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
66
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
67
-            ${_('You are viewing <b>an archived version</b> of the current page.')|n}
67
+            ${_('You are looking at an <b>archived page</b>.')|n}
68
         </p>
68
         </p>
69
     </div>
69
     </div>
70
     % elif (result.page.is_deleted) :
70
     % elif (result.page.is_deleted) :
71
     <div class="alert alert-info" role="alert">
71
     <div class="alert alert-info" role="alert">
72
         <p>
72
         <p>
73
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
73
             <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
74
-            ${_('You are viewing <b>a deleted version</b> of the current page.')|n}
74
+            ${_('You are looking at a <b>deleted page</b>.')|n}
75
         </p>
75
         </p>
76
     </div>
76
     </div>
77
     % endif
77
     % endif

+ 2 - 2
tracim/tracim/templates/thread/getone.mak View File

66
         <div class="">
66
         <div class="">
67
             <p>
67
             <p>
68
                 <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
68
                 <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
69
-                ${_('Vous consultez <b>une version archivée</b> de la page courante.')|n}
69
+                ${_('You are looking at an <b>archived thread</b>.')|n}
70
             </p>
70
             </p>
71
         </div>
71
         </div>
72
     </div>
72
     </div>
75
         <div class="">
75
         <div class="">
76
             <p>
76
             <p>
77
                 <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
77
                 <span class="pull-left"><i class="fa fa-fw fa-2x fa-warning" alt="" title=""></i></span>
78
-                ${_('Vous consultez <b>une version supprimée</b> de la page courante.')|n}
78
+                ${_('You are looking at a <b>deleted thread</b>.')|n}
79
             </p>
79
             </p>
80
         </div>
80
         </div>
81
     </div>
81
     </div>

+ 1 - 2
tracim/tracim/templates/widgets/left_menu.mak View File

2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
3
 
3
 
4
 <%def name="TREEVIEW(dom_id, selected_id='', uniq_workspace='0', css_classes='t-spacer-above')">
4
 <%def name="TREEVIEW(dom_id, selected_id='', uniq_workspace='0', css_classes='t-spacer-above')">
5
-    <h4 class="t-less-visible t-spacer-above textMenuColor">${_('Workspaces')}</h4>
5
+    <h4 class="t-spacer-above textMenuColor">${_('Workspaces')}</h4>
6
     <div id="sidebarleft_menu"></div>
6
     <div id="sidebarleft_menu"></div>
7
     <script src="${tg.url('/assets/js/sidebarleft.js')}"></script>
7
     <script src="${tg.url('/assets/js/sidebarleft.js')}"></script>
8
     <script>
8
     <script>
12
       })()
12
       })()
13
     </script>
13
     </script>
14
 </%def>
14
 </%def>
15
-

+ 4 - 4
tracim/tracim/templates/widgets/table_row.mak View File

65
         <!--td class="folder__content__list__type">
65
         <!--td class="folder__content__list__type">
66
             <span class="${content.type.color}">
66
             <span class="${content.type.color}">
67
                 % if (content.is_archived) :
67
                 % if (content.is_archived) :
68
-                    <i class="fa fa-archive fa-fw tracim-less-visible" title="Archivé"></i>
68
+                    <i class="fa fa-archive fa-fw tracim-less-visible" title="${_('archived')}"></i>
69
                 % elif (content.is_deleted) :
69
                 % elif (content.is_deleted) :
70
-                    <i class="fa fa-trash-o fa-fw tracim-less-visible" title="Supprimé"></i>
70
+                    <i class="fa fa-trash-o fa-fw tracim-less-visible" title="${_('deleted')}"></i>
71
                 % endif
71
                 % endif
72
                 ${content.type.label}
72
                 ${content.type.label}
73
             </span>
73
             </span>
95
                     <span class="t-less-visible">
95
                     <span class="t-less-visible">
96
                       ${content.status.label}
96
                       ${content.status.label}
97
                       % if (content.is_archived) :
97
                       % if (content.is_archived) :
98
-                          - Archivé
98
+                          - ${_('archived')}
99
                       % elif (content.is_deleted) :
99
                       % elif (content.is_deleted) :
100
-                          - Supprimé
100
+                          - ${_('deleted')}
101
                       % endif
101
                       % endif
102
                     </span>
102
                     </span>
103
                 </a>
103
                 </a>

+ 3 - 3
tracim/tracim/tests/functional/test_ldap_restrictions.py View File

26
         home = self.app.get('/home/',)
26
         home = self.app.get('/home/',)
27
 
27
 
28
         # HTML button is not here
28
         # HTML button is not here
29
-        eq_(None, BeautifulSoup(home.body).find(attrs={'class': 'change-password-btn'}))
29
+        eq_(None, BeautifulSoup(home.body, 'html.parser').find(attrs={'class': 'change-password-btn'}))
30
 
30
 
31
         # If we force passwd update, we got 403
31
         # If we force passwd update, we got 403
32
         try_post_passwd = self.app.post(
32
         try_post_passwd = self.app.post(
51
         edit = self.app.get('/user/5/edit')
51
         edit = self.app.get('/user/5/edit')
52
 
52
 
53
         # email input field is disabled
53
         # email input field is disabled
54
-        email_input = BeautifulSoup(edit.body).find(attrs={'id': 'email'})
54
+        email_input = BeautifulSoup(edit.body, 'html.parser').find(attrs={'id': 'email'})
55
         ok_('readonly' in email_input.attrs)
55
         ok_('readonly' in email_input.attrs)
56
         eq_(email_input.attrs['readonly'], "readonly")
56
         eq_(email_input.attrs['readonly'], "readonly")
57
 
57
 
58
         # Name is not (see attributes configuration of LDAP fixtures)
58
         # Name is not (see attributes configuration of LDAP fixtures)
59
-        name_input = BeautifulSoup(edit.body).find(attrs={'id': 'name'})
59
+        name_input = BeautifulSoup(edit.body, 'html.parser').find(attrs={'id': 'name'})
60
         ok_('readonly' not in name_input.attrs)
60
         ok_('readonly' not in name_input.attrs)
61
 
61
 
62
         # If we force edit of user, "email" field will be not updated
62
         # If we force edit of user, "email" field will be not updated

+ 1 - 1
tracim/tracim/tests/functional/test_root.py View File

30
         msg = 'copyright &copy; 2013 - {} tracim project.'.format(h.current_year())
30
         msg = 'copyright &copy; 2013 - {} tracim project.'.format(h.current_year())
31
         ok_(msg in response)
31
         ok_(msg in response)
32
 
32
 
33
-        forms = BeautifulSoup(response.body).find_all('form')
33
+        forms = BeautifulSoup(response.body, 'html.parser').find_all('form')
34
         print('FORMS = ',forms)
34
         print('FORMS = ',forms)
35
         eq_(1, len(forms))
35
         eq_(1, len(forms))
36
         eq_('w-login-form', forms[0].get('id'))
36
         eq_('w-login-form', forms[0].get('id'))

+ 759 - 0
tracim/tracim/tests/library/test_email_body_parser.py View File

1
+from bs4 import BeautifulSoup
2
+from nose.tools import raises
3
+
4
+from tracim.lib.email_processing.checkers import HtmlMailQuoteChecker
5
+from tracim.lib.email_processing.checkers import HtmlMailSignatureChecker
6
+from tracim.lib.email_processing.parser import ParsedHTMLMail
7
+from tracim.lib.email_processing.models import BodyMailPartType
8
+from tracim.lib.email_processing.models import BodyMailPart
9
+from tracim.lib.email_processing.models import BodyMailParts
10
+from tracim.tests import TestStandard
11
+
12
+
13
+class TestHtmlMailQuoteChecker(TestStandard):
14
+    def test_unit__is_standard_quote_ok(self):
15
+        soup = BeautifulSoup('<blockquote></blockquote>', 'html.parser')
16
+        main_elem = soup.find()
17
+        assert HtmlMailQuoteChecker._is_standard_quote(main_elem) is True
18
+
19
+    def test_unit__is_standard_quote_no(self):
20
+        soup = BeautifulSoup('<a></a>', 'html.parser')
21
+        main_elem = soup.find()
22
+        assert HtmlMailQuoteChecker._is_standard_quote(main_elem) is False
23
+
24
+    def test_unit__is_thunderbird_quote_ok(self):
25
+        soup = BeautifulSoup('<div class="moz-cite-prefix"></div>',
26
+                             'html.parser')
27
+        main_elem = soup.find()
28
+        assert HtmlMailQuoteChecker._is_thunderbird_quote(main_elem) is True
29
+
30
+    def test_unit__is_thunderbird_quote_no(self):
31
+        soup = BeautifulSoup('<div class="nothing"></div>', 'html.parser')
32
+        main_elem = soup.find()
33
+        assert HtmlMailQuoteChecker._is_thunderbird_quote(main_elem) is False
34
+
35
+    def test_unit__is_gmail_quote_ok(self):
36
+        html = '<div class="gmail_extra">' + \
37
+              '<a></a><div class="gmail_quote"></div>' + \
38
+              '</div>'
39
+        soup = BeautifulSoup(html, 'html.parser')
40
+        main_elem = soup.find()
41
+        assert HtmlMailQuoteChecker._is_gmail_quote(main_elem) is True
42
+
43
+    def test_unit__is_gmail_quote_no(self):
44
+        soup = BeautifulSoup('<div class="nothing"></div>', 'html.parser')
45
+        main_elem = soup.find()
46
+        assert HtmlMailQuoteChecker._is_gmail_quote(main_elem) is False
47
+
48
+    def test_unit__is_gmail_quote_no_2(self):
49
+        html = '<div class="gmail_extra">' + \
50
+              '<a></a><div class="gmail_signature"></div>' + \
51
+              '</div>'
52
+        soup = BeautifulSoup(html, 'html.parser')
53
+        main_elem = soup.find()
54
+        assert HtmlMailQuoteChecker._is_gmail_quote(main_elem) is False
55
+
56
+    def test_unit__is_outlook_com_quote_ok(self):
57
+        soup = BeautifulSoup('<div id="divRplyFwdMsg"></div>', 'html.parser')
58
+        main_elem = soup.find()
59
+        assert HtmlMailQuoteChecker._is_outlook_com_quote(main_elem) is True
60
+
61
+    def test_unit__is_outlook_com_quote_no(self):
62
+        soup = BeautifulSoup('<div id="Signature"></div>', 'html.parser')
63
+        main_elem = soup.find()
64
+        assert HtmlMailQuoteChecker._is_outlook_com_quote(main_elem) is False
65
+
66
+    # TODO - G.M - 2017-11-24 - Check Yahoo and New roundcube html mail with
67
+    # correct mail example
68
+
69
+
70
+class TestHtmlMailSignatureChecker(TestStandard):
71
+    def test_unit__is_thunderbird_signature_ok(self):
72
+        soup = BeautifulSoup('<div class="moz-signature"></div>', 'html.parser')
73
+        main_elem = soup.find()
74
+        assert HtmlMailSignatureChecker._is_thunderbird_signature(main_elem) is True  # nopep8
75
+
76
+    def test_unit__is_thunderbird_signature_no(self):
77
+        soup = BeautifulSoup('<div class="other"></div>', 'html.parser')
78
+        main_elem = soup.find()
79
+        assert HtmlMailSignatureChecker._is_thunderbird_signature(main_elem) is False  # nopep8
80
+
81
+    def test_unit__is_gmail_signature_ok(self):
82
+        html = '<div class="gmail_extra">' + \
83
+               '<a></a><div class="gmail_quote"></div>' + \
84
+               '</div>'
85
+        soup = BeautifulSoup(html, 'html.parser')
86
+        main_elem = soup.find()
87
+        assert HtmlMailSignatureChecker._is_gmail_signature(main_elem) is False
88
+
89
+    def test_unit__is_gmail_signature_no(self):
90
+        soup = BeautifulSoup('<div class="nothing"></div>', 'html.parser')
91
+        main_elem = soup.find()
92
+        assert HtmlMailSignatureChecker._is_gmail_signature(main_elem) is False
93
+
94
+    def test_unit__is_gmail_signature_yes(self):
95
+        html = '<div class="gmail_extra">' + \
96
+               '<a></a><div class="gmail_signature"></div>' + \
97
+               '</div>'
98
+        soup = BeautifulSoup(html, 'html.parser')
99
+        main_elem = soup.find()
100
+        assert HtmlMailSignatureChecker._is_gmail_signature(main_elem) is True
101
+
102
+    def test_unit__is_gmail_signature_yes_2(self):
103
+        html = '<div class="gmail_signature">' + \
104
+               '</div>'
105
+        soup = BeautifulSoup(html, 'html.parser')
106
+        main_elem = soup.find()
107
+        assert HtmlMailSignatureChecker._is_gmail_signature(main_elem) is True
108
+
109
+    def test_unit__is_outlook_com_signature_no(self):
110
+        soup = BeautifulSoup('<div id="divRplyFwdMsg"></div>', 'html.parser')
111
+        main_elem = soup.find()
112
+        assert HtmlMailSignatureChecker._is_outlook_com_signature(main_elem) \
113
+               is False
114
+
115
+    def test_unit__is_outlook_com_signature_ok(self):
116
+        soup = BeautifulSoup('<div id="Signature"></div>', 'html.parser')
117
+        main_elem = soup.find()
118
+        assert HtmlMailSignatureChecker._is_outlook_com_signature(main_elem) \
119
+               is True
120
+
121
+
122
+class TestBodyMailsParts(TestStandard):
123
+
124
+    def test_unit__std_list_methods(self):
125
+        mail_parts = BodyMailParts()
126
+        assert len(mail_parts) == 0
127
+        a = BodyMailPart('a', BodyMailPartType.Main)
128
+        mail_parts._list.append(a)
129
+        assert len(mail_parts) == 1
130
+        assert mail_parts[0] == a
131
+        del mail_parts[0]
132
+        assert len(mail_parts) == 0
133
+
134
+    def test_unit__append_same_type(self):
135
+        mail_parts = BodyMailParts()
136
+        a = BodyMailPart('a', BodyMailPartType.Main)
137
+        mail_parts._append(a)
138
+        b = BodyMailPart('b', BodyMailPartType.Main)
139
+        mail_parts._append(b)
140
+        assert len(mail_parts) == 1
141
+        assert mail_parts[0].part_type == BodyMailPartType.Main
142
+        assert mail_parts[0].text == 'ab'
143
+
144
+    def test_unit__append_different_type(self):
145
+        mail_parts = BodyMailParts()
146
+        a = BodyMailPart('a', BodyMailPartType.Main)
147
+        mail_parts.append(a)
148
+        b = BodyMailPart('b', BodyMailPartType.Quote)
149
+        mail_parts._append(b)
150
+        assert len(mail_parts) == 2
151
+        assert mail_parts[0] == a
152
+        assert mail_parts[1] == b
153
+
154
+    def test_unit__append_follow(self):
155
+        mail_parts = BodyMailParts()
156
+        mail_parts.follow = True
157
+        a = BodyMailPart('a', BodyMailPartType.Main)
158
+        mail_parts._append(a)
159
+        b = BodyMailPart('b', BodyMailPartType.Quote)
160
+        mail_parts._append(b)
161
+        assert len(mail_parts) == 1
162
+        assert mail_parts[0].part_type == BodyMailPartType.Main
163
+        assert mail_parts[0].text == 'ab'
164
+
165
+    def test_unit__append_dont_follow_when_first(self):
166
+        mail_parts = BodyMailParts()
167
+        a = BodyMailPart('a', BodyMailPartType.Main)
168
+        mail_parts._append(a, follow=True)
169
+        assert len(mail_parts) == 1
170
+        assert mail_parts[0].part_type == BodyMailPartType.Main
171
+        assert mail_parts[0].text == 'a'
172
+
173
+    @raises(TypeError)
174
+    def test_unit__check_value__type_error(self):
175
+        mail_parts = BodyMailParts()
176
+        mail_parts._check_value('a')
177
+
178
+    def test_unit__check_value__ok(self):
179
+        mail_parts = BodyMailParts()
180
+        a = BodyMailPart('a', BodyMailPartType.Main)
181
+        mail_parts._check_value(a)
182
+
183
+    def test_unit__drop_part_type(self):
184
+        mail_parts = BodyMailParts()
185
+        a = BodyMailPart('a', BodyMailPartType.Main)
186
+        mail_parts._list.append(a)
187
+        b = BodyMailPart('b', BodyMailPartType.Quote)
188
+        mail_parts._list.append(b)
189
+        c = BodyMailPart('c', BodyMailPartType.Signature)
190
+        mail_parts._list.append(c)
191
+        mail_parts.drop_part_type(BodyMailPartType.Quote)
192
+        assert len(mail_parts) == 2
193
+        assert mail_parts[0].text == 'a'
194
+        assert mail_parts[0].part_type == BodyMailPartType.Main
195
+        assert len(mail_parts) == 2
196
+        assert mail_parts[1].text == 'c'
197
+        assert mail_parts[1].part_type == BodyMailPartType.Signature
198
+
199
+    def test_unit__drop_part_type_verify_no_follow_incidence(self):
200
+        mail_parts = BodyMailParts()
201
+        a = BodyMailPart('a', BodyMailPartType.Main)
202
+        mail_parts._list.append(a)
203
+        b = BodyMailPart('b', BodyMailPartType.Quote)
204
+        mail_parts._list.append(b)
205
+        c = BodyMailPart('c', BodyMailPartType.Signature)
206
+        mail_parts._list.append(c)
207
+        mail_parts.follow = True
208
+        mail_parts.drop_part_type(BodyMailPartType.Quote)
209
+        assert len(mail_parts) == 2
210
+        assert mail_parts[0].text == 'a'
211
+        assert mail_parts[0].part_type == BodyMailPartType.Main
212
+        assert len(mail_parts) == 2
213
+        assert mail_parts[1].text == 'c'
214
+        assert mail_parts[1].part_type == BodyMailPartType.Signature
215
+
216
+    def test_unit__drop_part_type_consistence(self):
217
+        mail_parts = BodyMailParts()
218
+        a = BodyMailPart('a', BodyMailPartType.Main)
219
+        mail_parts._list.append(a)
220
+        b = BodyMailPart('b', BodyMailPartType.Quote)
221
+        mail_parts._list.append(b)
222
+        c = BodyMailPart('c', BodyMailPartType.Main)
223
+        mail_parts._list.append(c)
224
+        mail_parts.drop_part_type(BodyMailPartType.Quote)
225
+        assert len(mail_parts) == 1
226
+        assert mail_parts[0].text == 'ac'
227
+        assert mail_parts[0].part_type == BodyMailPartType.Main
228
+
229
+    def test_unit__get_nb_part_type(self):
230
+        mail_parts = BodyMailParts()
231
+        assert mail_parts.get_nb_part_type(BodyMailPartType.Main) == 0
232
+        assert mail_parts.get_nb_part_type(BodyMailPartType.Quote) == 0
233
+        assert mail_parts.get_nb_part_type(BodyMailPartType.Signature) == 0
234
+        a = BodyMailPart('a', BodyMailPartType.Main)
235
+        mail_parts._list.append(a)
236
+        assert mail_parts.get_nb_part_type(BodyMailPartType.Main) == 1
237
+        b = BodyMailPart('b', BodyMailPartType.Quote)
238
+        mail_parts._list.append(b)
239
+        assert mail_parts.get_nb_part_type(BodyMailPartType.Quote) == 1
240
+        c = BodyMailPart('c', BodyMailPartType.Signature)
241
+        mail_parts._list.append(c)
242
+        assert mail_parts.get_nb_part_type(BodyMailPartType.Main) == 1
243
+        assert mail_parts.get_nb_part_type(BodyMailPartType.Quote) == 1
244
+        assert mail_parts.get_nb_part_type(BodyMailPartType.Signature) == 1
245
+
246
+    def test_unit__str(self):
247
+        mail_parts = BodyMailParts()
248
+        a = BodyMailPart('a', BodyMailPartType.Main)
249
+        mail_parts._list.append(a)
250
+        b = BodyMailPart('b', BodyMailPartType.Quote)
251
+        mail_parts._list.append(b)
252
+        c = BodyMailPart('c', BodyMailPartType.Signature)
253
+        mail_parts._list.append(c)
254
+        assert str(mail_parts) == 'abc'
255
+
256
+
257
+class TestParsedMail(TestStandard):
258
+
259
+    def test_other__check_gmail_mail_text_only(self):
260
+        text_only = '''<div dir="ltr">Voici le texte<br></div>'''
261
+        mail = ParsedHTMLMail(text_only)
262
+        elements = mail.get_elements()
263
+        assert len(elements) == 1
264
+        assert elements[0].part_type == BodyMailPartType.Main
265
+
266
+    def test_other__check_gmail_mail_text_signature(self):
267
+        text_and_signature = '''
268
+        <div dir="ltr">POF<br clear="all"><div><br>-- <br>
269
+        <div class="gmail_signature" data-smartmail="gmail_signature">
270
+        <div dir="ltr">Voici Ma signature. En HTML <br><ol>
271
+        <li>Plop</li>
272
+        <li>Plip</li>
273
+        <li>Plop<br>
274
+        </li></ol></div></div></div></div>
275
+        '''
276
+        mail = ParsedHTMLMail(text_and_signature)
277
+        elements = mail.get_elements()
278
+        assert len(elements) == 2
279
+        assert elements[0].part_type == BodyMailPartType.Main
280
+        assert elements[1].part_type == BodyMailPartType.Signature
281
+
282
+    def test_other__check_gmail_mail_text_quote(self):
283
+        text_and_quote = '''
284
+        <div dir="ltr">Réponse<br>
285
+        <div class="gmail_extra"><br>
286
+        <div class="gmail_quote">Le 28 novembre 2017 à 10:29, John Doe <span
287
+        dir="ltr">&lt;<a href="mailto:bidule@localhost.fr"
288
+        target="_blank">bidule@localhost.fr</a>&gt;</span>
289
+        a écrit :<br>
290
+        <blockquote class="gmail_quote" style="margin:0 0 0
291
+        .8ex;border-left:1px #ccc solid;padding-left:1ex">Voici ma réponse<br>
292
+        <br><br>
293
+        Le 28/11/2017 à 10:05, Foo Bar a écrit&nbsp;:<br>
294
+        <blockquote class="gmail_quote" style="margin:0 0 0
295
+        .8ex;border-left:1px #ccc solid;padding-left:1ex">
296
+        Voici le texte<span class="HOEnZb"><font color="#888888"><br>
297
+        </font></span></blockquote>
298
+        <span class="HOEnZb"><font color="#888888">
299
+        <br>
300
+        -- <br>
301
+        TEST DE signature<br>
302
+        </font></span></blockquote>
303
+        </div><br></div></div>
304
+        '''
305
+        mail = ParsedHTMLMail(text_and_quote)
306
+        elements = mail.get_elements()
307
+        assert len(elements) == 2
308
+        assert elements[0].part_type == BodyMailPartType.Main
309
+        assert elements[1].part_type == BodyMailPartType.Quote
310
+
311
+    def test_other__check_gmail_mail_text_quote_text(self):
312
+        text_quote_text = '''
313
+              <div dir="ltr">Avant<br>
314
+              <div class="gmail_extra"><br>
315
+              <div class="gmail_quote">Le 28 novembre 2017 à 10:29, John Doe 
316
+              <span dir="ltr">&lt;<a href="mailto:bidule@localhost.fr"
317
+              target="_blank">bidule@localhost.fr</a>&gt;</span>
318
+              a écrit :<br>
319
+              <blockquote class="gmail_quote" style="margin:0 0 0
320
+              .8ex;border-left:1px #ccc solid;padding-left:1ex">Voici ma
321
+              réponse<br>
322
+              <br>
323
+              <br>
324
+              Le 28/11/2017 à 10:05, Foo Bar a écrit&nbsp;:<br>
325
+              <blockquote class="gmail_quote" style="margin:0 0 0
326
+              .8ex;border-left:1px #ccc solid;padding-left:1ex">
327
+              Voici le texte<span class="HOEnZb"><font color="#888888"><br>
328
+              </font></span></blockquote>
329
+              <span class="HOEnZb"><font color="#888888">
330
+              <br>
331
+              -- <br>
332
+              TEST DE signature<br>
333
+              </font></span></blockquote>
334
+              </div>
335
+              <br>
336
+              </div>
337
+              <div class="gmail_extra">Aprés<br>
338
+              </div>
339
+              </div>
340
+              '''
341
+
342
+        mail = ParsedHTMLMail(text_quote_text)
343
+        elements = mail.get_elements()
344
+        assert len(elements) == 3
345
+        assert elements[0].part_type == BodyMailPartType.Main
346
+        assert elements[1].part_type == BodyMailPartType.Quote
347
+        assert elements[2].part_type == BodyMailPartType.Main
348
+
349
+    def test_other__check_gmail_mail_text_quote_signature(self):
350
+        text_quote_signature = '''
351
+        <div dir="ltr">Hey !<br>
352
+                 </div>
353
+                 <div class="gmail_extra"><br>
354
+                 <div class="gmail_quote">Le 28 novembre 2017 à 10:29,
355
+                  John Doe <span
356
+                 dir="ltr">&lt;<a href="mailto:bidule@localhost.fr"
357
+                 target="_blank">bidule@localhost.fr</a>&gt;</span>
358
+                 a écrit :<br>
359
+                 <blockquote class="gmail_quote" style="margin:0 0 0
360
+                 .8ex;border-left:1px #ccc solid;padding-left:1ex">Voici ma
361
+                 réponse<br>
362
+                 <br>
363
+                 <br>
364
+                  Le 28/11/2017 à 10:05, Foo Bar a écrit&nbsp;:<br>
365
+                  <blockquote class="gmail_quote" style="margin:0 0 0
366
+                  .8ex;border-left:1px #ccc solid;padding-left:1ex">
367
+                  Voici le texte<span class="HOEnZb"><font color="#888888"><br>
368
+                  </font></span></blockquote>
369
+                  <span class="HOEnZb"><font color="#888888">
370
+                  <br>
371
+                  -- <br>
372
+                  TEST DE signature<br>
373
+                  </font></span></blockquote>
374
+                  </div>
375
+                  <br>
376
+                  <br clear="all">
377
+                  <br>
378
+                  -- <br>
379
+                  <div class="gmail_signature" data-smartmail="gmail_signature">
380
+                  <div dir="ltr">Voici Ma signature. En HTML <br>
381
+                  <ol>
382
+                  <li>Plop</li>
383
+                  <li>Plip</li>
384
+                  <li>Plop<br>
385
+                  </li>
386
+                  </ol>
387
+                  </div>
388
+                  </div>
389
+                  </div>
390
+                 '''
391
+
392
+        # INFO - G.M - 2017-11-28 -
393
+        # Now Quote + Signature block in Gmail is considered as one Quote
394
+        # Block.
395
+        mail = ParsedHTMLMail(text_quote_signature)
396
+        elements = mail.get_elements()
397
+        assert len(elements) == 2
398
+        assert elements[0].part_type == BodyMailPartType.Main
399
+        assert elements[1].part_type == BodyMailPartType.Quote
400
+
401
+    def test_other__check_gmail_mail_text_quote_text_signature(self):
402
+        text_quote_text_sign = '''
403
+        <div dir="ltr">Test<br>
404
+        <div class="gmail_extra"><br>
405
+        <div class="gmail_quote">Le 28 novembre 2017 à 10:29, John Doe <span
406
+        dir="ltr">&lt;<a href="mailto:bidule@localhost.fr"
407
+        target="_blank">bidule@localhost.fr</a>&gt;</span>
408
+        a écrit :<br>
409
+        <blockquote class="gmail_quote" style="margin:0 0 0
410
+        .8ex;border-left:1px #ccc solid;padding-left:1ex">Voici ma
411
+        réponse<br>
412
+        <br>
413
+        <br>
414
+        Le 28/11/2017 à 10:05, Foo Bar a écrit&nbsp;:<br>
415
+        <blockquote class="gmail_quote" style="margin:0 0 0
416
+        .8ex;border-left:1px #ccc solid;padding-left:1ex">
417
+        Voici le texte<span class="HOEnZb"><font color="#888888"><br>
418
+        </font></span></blockquote>
419
+        <span class="HOEnZb"><font color="#888888">
420
+        <br>
421
+        -- <br>
422
+        TEST DE signature<br>
423
+        </font></span></blockquote>
424
+        </div>
425
+        <br>
426
+        <br>
427
+        </div>
428
+        <div class="gmail_extra">RE test<br clear="all">
429
+        </div>
430
+        <div class="gmail_extra"><br>
431
+        -- <br>
432
+        <div class="gmail_signature" data-smartmail="gmail_signature">
433
+        <div dir="ltr">Voici Ma signature. En HTML <br>
434
+        <ol>
435
+        <li>Plop</li>
436
+        <li>Plip</li>
437
+        <li>Plop<br>
438
+        </li>
439
+        </ol>
440
+        </div>
441
+        </div>
442
+        </div>
443
+        </div>
444
+        '''
445
+
446
+        mail = ParsedHTMLMail(text_quote_text_sign)
447
+        elements = mail.get_elements()
448
+        assert len(elements) == 4
449
+        assert elements[0].part_type == BodyMailPartType.Main
450
+        assert elements[1].part_type == BodyMailPartType.Quote
451
+        assert elements[2].part_type == BodyMailPartType.Main
452
+        assert elements[3].part_type == BodyMailPartType.Signature
453
+
454
+    def test_other__check_thunderbird_mail_text_only(self):
455
+
456
+        text_only = '''Coucou<br><br><br>'''
457
+        mail = ParsedHTMLMail(text_only)
458
+        elements = mail.get_elements()
459
+        assert len(elements) == 1
460
+        assert elements[0].part_type == BodyMailPartType.Main
461
+
462
+    def test_other__check_thunderbird_mail_text_signature(self):
463
+        text_and_signature = '''
464
+        <p>Test<br>
465
+        </p>
466
+        <div class="moz-signature">-- <br>
467
+          TEST DE signature</div>
468
+        '''
469
+        mail = ParsedHTMLMail(text_and_signature)
470
+        elements = mail.get_elements()
471
+        assert len(elements) == 2
472
+        assert elements[0].part_type == BodyMailPartType.Main
473
+        assert elements[1].part_type == BodyMailPartType.Signature
474
+
475
+    def test_other__check_thunderbird_mail_text_quote(self):
476
+        text_and_quote = '''
477
+            <p>Pof<br>
478
+            </p>
479
+            <br>
480
+            <div class="moz-cite-prefix">Le 28/11/2017 à 11:21, John Doe a
481
+              écrit&nbsp;:<br>
482
+            </div>
483
+            <blockquote type="cite"
484
+              cite="mid:658592c1-14de-2958-5187-3571edea0aac@localhost.fr">
485
+              <meta http-equiv="Context-Type" 
486
+              content="text/html; charset=utf-8">
487
+              <p>Test<br>
488
+              </p>
489
+              <div class="moz-signature">-- <br>
490
+                TEST DE signature</div>
491
+            </blockquote>
492
+            <br>
493
+        '''
494
+        mail = ParsedHTMLMail(text_and_quote)
495
+        elements = mail.get_elements()
496
+        assert len(elements) == 2
497
+        assert elements[0].part_type == BodyMailPartType.Main
498
+        assert elements[1].part_type == BodyMailPartType.Quote
499
+
500
+    def test_other__check_thunderbird_mail_text_quote_text(self):
501
+        text_quote_text = '''
502
+        <p>Pof<br>
503
+        </p>
504
+        <br>
505
+        <div class="moz-cite-prefix">Le 28/11/2017 à 11:54, 
506
+         Bidule a
507
+          écrit&nbsp;:<br>
508
+        </div>
509
+        <blockquote type="cite"
510
+          cite="mid:b541b451-bb31-77a4-45b9-ad89969d7962@localhost.fr">
511
+          <meta http-equiv="Context-Type" 
512
+          content="text/html; charset=utf-8">
513
+          <p>Pof<br>
514
+          </p>
515
+          <br>
516
+          <div class="moz-cite-prefix">Le 28/11/2017 à 11:21, John Doe a
517
+            écrit&nbsp;:<br>
518
+          </div>
519
+          <blockquote type="cite"
520
+            cite="mid:658592c1-14de-2958-5187-3571edea0aac@localhost.fr">
521
+            <p>Test<br>
522
+            </p>
523
+            <div class="moz-signature">-- <br>
524
+              TEST DE signature</div>
525
+          </blockquote>
526
+          <br>
527
+        </blockquote>
528
+        Pif<br>
529
+        '''
530
+
531
+        mail = ParsedHTMLMail(text_quote_text)
532
+        elements = mail.get_elements()
533
+        assert len(elements) == 3
534
+        assert elements[0].part_type == BodyMailPartType.Main
535
+        assert elements[1].part_type == BodyMailPartType.Quote
536
+        assert elements[2].part_type == BodyMailPartType.Main
537
+
538
+    def test_other__check_thunderbird_mail_text_quote_signature(self):
539
+        text_quote_signature = '''
540
+        <p>Coucou<br>
541
+        </p>
542
+        <br>
543
+        <div class="moz-cite-prefix">Le 28/11/2017 à 11:22, Bidule a
544
+        écrit&nbsp;:<br>
545
+        </div>
546
+        <blockquote type="cite"
547
+        cite="mid:4e6923e2-796d-eccf-84b7-6824da4151ee@localhost.fr">Réponse<br>
548
+        <br>
549
+        Le 28/11/2017 à 11:21, John Doe a écrit&nbsp;: <br>
550
+        <blockquote type="cite"> <br>
551
+        Test <br>
552
+        <br>
553
+        --&nbsp;<br>
554
+        TEST DE signature <br>
555
+        </blockquote>
556
+        <br>
557
+        </blockquote>
558
+        <br>
559
+        <div class="moz-signature">-- <br>
560
+        TEST DE signature</div>
561
+        '''
562
+
563
+        mail = ParsedHTMLMail(text_quote_signature)
564
+        elements = mail.get_elements()
565
+        assert len(elements) == 3
566
+        assert elements[0].part_type == BodyMailPartType.Main
567
+        assert elements[1].part_type == BodyMailPartType.Quote
568
+        assert elements[2].part_type == BodyMailPartType.Signature
569
+
570
+    def test_other__check_thunderbird_mail_text_quote_text_signature(self):
571
+        text_quote_text_sign = '''
572
+        <p>Avant<br>
573
+        </p>
574
+        <br>
575
+        <div class="moz-cite-prefix">Le 28/11/2017 à 11:19, Bidule a
576
+          écrit&nbsp;:<br>
577
+        </div>
578
+        <blockquote type="cite"
579
+          cite="mid:635df73c-d3c9-f2e9-2304-24ff536bfa16@localhost.fr">Coucou 
580
+          <br><br>
581
+        </blockquote>
582
+        Aprés<br>
583
+        <br>
584
+        <div class="moz-signature">-- <br>
585
+          TEST DE signature</div>
586
+        '''
587
+
588
+        mail = ParsedHTMLMail(text_quote_text_sign)
589
+        elements = mail.get_elements()
590
+        assert len(elements) == 4
591
+        assert elements[0].part_type == BodyMailPartType.Main
592
+        assert elements[1].part_type == BodyMailPartType.Quote
593
+        assert elements[2].part_type == BodyMailPartType.Main
594
+        assert elements[3].part_type == BodyMailPartType.Signature
595
+
596
+    # INFO - G.M - 2017-11-28 - Test for outlook.com webapp html mail
597
+    # outlook.com ui doesn't seems to allow complex reply, new message
598
+    # and signature are always before quoted one.
599
+
600
+    def test_other__check_outlook_com_mail_text_only(self):
601
+
602
+        text_only = '''
603
+        <div id="divtagdefaultwrapper"
604
+        style="font-size:12pt;color:#000000;
605
+        font-family:Calibri,Helvetica,sans-serif;"
606
+        dir="ltr">
607
+        <p style="margin-top:0;margin-bottom:0">message<br>
608
+        </p>
609
+        </div>
610
+        '''
611
+        mail = ParsedHTMLMail(text_only)
612
+        elements = mail.get_elements()
613
+        assert len(elements) == 1
614
+        assert elements[0].part_type == BodyMailPartType.Main
615
+
616
+    def test_other__check_outlook_com_mail_text_signature(self):
617
+        text_and_signature = '''
618
+        <div id="divtagdefaultwrapper"
619
+        style="font-size:12pt;color:#000000;
620
+        font-family:Calibri,Helvetica,sans-serif;"
621
+          dir="ltr">
622
+          <p style="margin-top:0;margin-bottom:0">Test<br>
623
+          </p>
624
+          <p style="margin-top:0;margin-bottom:0"><br>
625
+          </p>
626
+          <div id="Signature">
627
+            <div id="divtagdefaultwrapper" style="font-size: 12pt; color:
628
+              rgb(0, 0, 0); background-color: rgb(255, 255, 255);
629
+              font-family:
630
+              Calibri,Arial,Helvetica,sans-serif,&quot;EmojiFont&quot;,&quot;Apple
631
+              Color Emoji&quot;,&quot;Segoe UI
632
+              Emoji&quot;,NotoColorEmoji,&quot;Segoe UI
633
+              Symbol&quot;,&quot;Android Emoji&quot;,EmojiSymbols;">
634
+              Envoyé à partir de <a href="http://aka.ms/weboutlook"
635
+                id="LPNoLP">Outlook</a></div>
636
+          </div>
637
+        </div>
638
+        '''
639
+        mail = ParsedHTMLMail(text_and_signature)
640
+        elements = mail.get_elements()
641
+        assert len(elements) == 2
642
+        assert elements[0].part_type == BodyMailPartType.Main
643
+        assert elements[1].part_type == BodyMailPartType.Signature
644
+
645
+    def test_other__check_outlook_com_mail_text_quote(self):
646
+        text_and_quote = '''
647
+        <div id="divtagdefaultwrapper"
648
+        style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;"
649
+        dir="ltr">
650
+        <p style="margin-top:0;margin-bottom:0">Salut !<br>
651
+        </p>
652
+        </div>
653
+        <hr style="display:inline-block;width:98%" tabindex="-1">
654
+        <div id="divRplyFwdMsg" dir="ltr"><font style="font-size:11pt"
655
+        color="#000000" face="Calibri, sans-serif"><b>De :</b> John Doe<br>
656
+        <b>Envoyé :</b> mardi 28 novembre 2017 12:44:59<br>
657
+        <b>À :</b> dev.bidule@localhost.fr<br>
658
+        <b>Objet :</b> voila</font>
659
+        <div>&nbsp;</div>
660
+        </div>
661
+        <style type="text/css" style="display:none">
662
+        <!--
663
+        p
664
+        &#x09;{margin-top:0;
665
+        &#x09;margin-bottom:0}
666
+        -->
667
+        </style>
668
+        <div dir="ltr">
669
+        <div id="x_divtagdefaultwrapper" dir="ltr" style="font-size:12pt;
670
+        color:#000000; font-family:Calibri,Helvetica,sans-serif">
671
+        Contenu
672
+        <p style="margin-top:0; margin-bottom:0"><br>
673
+        </p>
674
+        <div id="x_Signature">
675
+          <div id="x_divtagdefaultwrapper" dir="ltr"
676
+            style="font-size:12pt; color:rgb(0,0,0);
677
+            background-color:rgb(255,255,255);
678
+        font-family:Calibri,Arial,Helvetica,sans-serif,&quot;EmojiFont&quot;,&quot;Apple
679
+            Color Emoji&quot;,&quot;Segoe UI
680
+            Emoji&quot;,NotoColorEmoji,&quot;Segoe UI
681
+            Symbol&quot;,&quot;Android Emoji&quot;,EmojiSymbols">
682
+            DLMQDNLQNDMLQS<br>
683
+            qs<br>
684
+            dqsd<br>
685
+            d<br>
686
+            qsd<br>
687
+          </div>
688
+        </div>
689
+        </div>
690
+        </div>
691
+        '''
692
+        mail = ParsedHTMLMail(text_and_quote)
693
+        elements = mail.get_elements()
694
+        assert len(elements) == 2
695
+        assert elements[0].part_type == BodyMailPartType.Main
696
+        assert elements[1].part_type == BodyMailPartType.Quote
697
+
698
+    def test_other__check_outlook_com_mail_text_signature_quote(self):
699
+        text_signature_quote = '''
700
+        <div id="divtagdefaultwrapper"
701
+        style="font-size:12pt;color:#000000;font-family:Calibri,Helvetica,sans-serif;"
702
+        dir="ltr">
703
+        <p style="margin-top:0;margin-bottom:0">Salut !<br>
704
+        </p>
705
+        <p style="margin-top:0;margin-bottom:0"><br>
706
+        </p>
707
+        <div id="Signature">
708
+        <div id="divtagdefaultwrapper" dir="ltr" style="font-size: 12pt;
709
+        color: rgb(0, 0, 0); background-color: rgb(255, 255, 255);
710
+        font-family:
711
+        Calibri,Arial,Helvetica,sans-serif,&quot;EmojiFont&quot;,&quot;Apple
712
+        Color Emoji&quot;,&quot;Segoe UI
713
+        Emoji&quot;,NotoColorEmoji,&quot;Segoe UI
714
+        Symbol&quot;,&quot;Android Emoji&quot;,EmojiSymbols;">
715
+        Envoyée depuis Outlook<br>
716
+        </div>
717
+        </div>
718
+        </div>
719
+        <hr style="display:inline-block;width:98%" tabindex="-1">
720
+        <div id="divRplyFwdMsg" dir="ltr"><font style="font-size:11pt"
721
+        color="#000000" face="Calibri, sans-serif"><b>De :</b> John Doe
722
+        &lt;dev.bidule@localhost.fr&gt;<br>
723
+        <b>Envoyé :</b> mardi 28 novembre 2017 12:51:42<br>
724
+        <b>À :</b> John Doe<br>
725
+        <b>Objet :</b> Re: Test</font>
726
+        <div>&nbsp;</div>
727
+        </div>
728
+        <div style="background-color:#FFFFFF">
729
+        <p>Coucou<br>
730
+        </p>
731
+        <br>
732
+        <div class="x_moz-cite-prefix">Le 28/11/2017 à 12:39, John Doe a
733
+        écrit&nbsp;:<br>
734
+        </div>
735
+        <blockquote type="cite">
736
+        <div id="x_divtagdefaultwrapper" dir="ltr">
737
+        <p>Test<br>
738
+        </p>
739
+        <p><br>
740
+        </p>
741
+        <div id="x_Signature">
742
+        <div id="x_divtagdefaultwrapper">Envoyé à partir de <a
743
+        href="http://aka.ms/weboutlook" id="LPNoLP">
744
+        Outlook</a></div>
745
+        </div>
746
+        </div>
747
+        </blockquote>
748
+        <br>
749
+        <div class="x_moz-signature">-- <br>
750
+        TEST DE signature</div>
751
+        </div>
752
+        '''
753
+
754
+        mail = ParsedHTMLMail(text_signature_quote)
755
+        elements = mail.get_elements()
756
+        assert len(elements) == 3
757
+        assert elements[0].part_type == BodyMailPartType.Main
758
+        assert elements[1].part_type == BodyMailPartType.Signature
759
+        assert elements[2].part_type == BodyMailPartType.Quote

+ 11 - 0
tracim/tracim/tests/library/test_email_fetcher.py View File

1
+from tracim.lib.email_fetcher import DecodedMail
2
+from tracim.tests import TestStandard
3
+
4
+class TestDecodedMail(TestStandard):
5
+    def test_unit__find_key_from_mail_address_no_key(self):
6
+        mail_address = "a@b"
7
+        assert DecodedMail.find_key_from_mail_address(mail_address) is None
8
+
9
+    def test_unit__find_key_from_mail_adress_key(self):
10
+        mail_address = "a+key@b"
11
+        assert DecodedMail.find_key_from_mail_address(mail_address) == 'key'

+ 0 - 20
update.sh View File

1
-#!/bin/bash
2
-user="root"
3
-command="npm install \
4
-&& gulp prod"
5
-echo "############################################################################"
6
-echo "############################################################################"
7
-echo "## "
8
-echo "## UPDATE ----------"
9
-echo "## "
10
-echo "##" `date` Execute as $user: $command
11
-echo "## "
12
-echo "## "
13
-echo "############################################################################"
14
-if ! command -v npm >/dev/null; then
15
-  echo ""
16
-  echo "/!\ npm doesn't seem to be installed. Aborting."
17
-  echo ""
18
-  exit 1
19
-fi
20
-sudo -u $user -- bash -c "$command"