ソースを参照

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

Skylsmoi 6 年 前
コミット
3c1511a622
共有51 個のファイルを変更した2507 個の追加1146 個の削除を含む
  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 ファイルの表示

@@ -70,6 +70,7 @@ wsgidav.conf
70 70
 # Temporary files
71 71
 *~
72 72
 *.sqlite
73
+*.lock
73 74
 
74 75
 # npm packages
75 76
 /node_modules/

+ 4 - 2
README.md ファイルの表示

@@ -102,7 +102,9 @@ If you want your own dedicated instance but do not want to manage it by yourself
102 102
 
103 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 109
 ## Install Tracim on your server ##
108 110
 
@@ -116,7 +118,7 @@ Following the installation documentation below, you'll be able to run your own i
116 118
 
117 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 122
                      python3 python-virtualenv python3-dev python-pip  python-lxml \
121 123
                      build-essential libxml2-dev libxslt1-dev zlib1g-dev libjpeg-dev \
122 124
                      libmagickwand-6.q16-3

+ 0 - 75
bin/setup.sh ファイルの表示

@@ -1,75 +0,0 @@
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 ファイルの表示

@@ -57,8 +57,8 @@ Failure output:
57 57
 In this case, delete the user and database and start over:
58 58
 
59 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 63
 [//]: # (The following lines are only necessary to fix permissions on an existing database:)
64 64
 [//]: # (    sudo --user=postgres psql \)
@@ -88,7 +88,7 @@ Connect to `MySQL` with root user (password has been set at "Installation" -> "D
88 88
 
89 89
 Create a database with following command:
90 90
 
91
-    CREATE DATABASE tracimdb;
91
+    CREATE DATABASE tracimdb CHARACTER SET = utf8;
92 92
 
93 93
 Create a user with following command:
94 94
 

+ 1 - 1
install/requirements.postgresql.txt ファイルの表示

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

+ 5 - 2
install/requirements.txt ファイルの表示

@@ -17,7 +17,7 @@ WebTest==1.4.2
17 17
 alembic==0.8.4
18 18
 argparse==1.2.1
19 19
 backlash==0.0.7
20
-beautifulsoup4==4.4.0
20
+beautifulsoup4==4.6.0
21 21
 caldav==0.4.0
22 22
 cliff==2.9.1
23 23
 cmd2==0.6.9
@@ -57,7 +57,7 @@ unicode-slugify==0.1.3
57 57
 vobject==0.9.2
58 58
 waitress==0.8.9
59 59
 who_ldap==3.2.2
60
--e git+https://github.com/algoo/wsgidav.git@py3#egg=wsgidav
60
+wsgidav==2.2.4
61 61
 zope.interface==4.1.3
62 62
 zope.sqlalchemy==0.7.6
63 63
 PyYAML
@@ -65,3 +65,6 @@ redis==2.10.5
65 65
 typing==3.5.3.0
66 66
 rq==0.7.1
67 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 ファイルの表示

@@ -190,6 +190,8 @@ email.notification.activated = False
190 190
 # notifications generated by a user or another one
191 191
 email.notification.from.email = noreply+{user_id}@trac.im
192 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 195
 email.notification.content_update.template.html = %(here)s/tracim/templates/mail/content_update_body_html.mak
194 196
 email.notification.content_update.template.text = %(here)s/tracim/templates/mail/content_update_body_text.mak
195 197
 email.notification.created_account.template.html = %(here)s/tracim/templates/mail/created_account_body_html.mak
@@ -212,6 +214,24 @@ email.processing_mode = sync
212 214
 # email.async.redis.port = 6379
213 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 235
 ## Radical (CalDav server) configuration
216 236
 # radicale.server.host = 0.0.0.0
217 237
 # radicale.server.port = 5232

+ 0 - 3
tracim/setup.py ファイルの表示

@@ -86,8 +86,5 @@ setup(
86 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 89
     zip_safe=False,
93 90
 )

+ 57 - 0
tracim/tracim/config/app_cfg.py ファイルの表示

@@ -28,6 +28,7 @@ from tracim.config import TracimAppConfig
28 28
 from tracim.lib.base import logger
29 29
 from tracim.lib.daemons import DaemonsManager
30 30
 from tracim.lib.daemons import MailSenderDaemon
31
+from tracim.lib.daemons import MailFetcherDaemon
31 32
 from tracim.lib.daemons import RadicaleDaemon
32 33
 from tracim.lib.daemons import WsgiDavDaemon
33 34
 from tracim.lib.system import InterruptManager
@@ -126,6 +127,9 @@ def start_daemons(manager: DaemonsManager):
126 127
     if cfg.EMAIL_PROCESSING_MODE == CFG.CST.ASYNC:
127 128
         manager.run('mail_sender', MailSenderDaemon)
128 129
 
130
+    if cfg.EMAIL_REPLY_ACTIVATED:
131
+        manager.run('mail_fetcher', MailFetcherDaemon)
132
+
129 133
 
130 134
 def configure_depot():
131 135
     """Configure Depot."""
@@ -299,6 +303,12 @@ class CFG(object):
299 303
         self.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL = tg.config.get(
300 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 312
         self.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_HTML = tg.config.get(
303 313
             'email.notification.content_update.template.html',
304 314
         )
@@ -344,6 +354,53 @@ class CFG(object):
344 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 404
         self.TRACKER_JS_PATH = tg.config.get(
348 405
             'js_tracker_path',
349 406
         )

+ 100 - 0
tracim/tracim/controllers/events.py ファイルの表示

@@ -0,0 +1,100 @@
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 ファイルの表示

@@ -22,6 +22,7 @@ from tracim.controllers.help import HelpController
22 22
 from tracim.controllers.previews import PreviewsController
23 23
 from tracim.controllers.user import UserRestController
24 24
 from tracim.controllers.workspace import UserWorkspaceRestController
25
+from tracim.controllers.events import EventRestController
25 26
 from tracim.lib import CST
26 27
 from tracim.lib.base import logger
27 28
 from tracim.lib.content import ContentApi
@@ -61,7 +62,7 @@ class RootController(StandardController):
61 62
     previews = PreviewsController()
62 63
 
63 64
     content = ContentController()
64
-
65
+    events = EventRestController()
65 66
     # api
66 67
     api = APIController()
67 68
 
@@ -154,19 +155,6 @@ class RootController(StandardController):
154 155
         fake_api.favorites = Context(CTX.CONTENT_LIST).toDict(items, 'contents', 'nb')
155 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 158
     @require(predicates.not_anonymous())
171 159
     @expose('tracim.templates.search.display')
172 160
     def search(self, keywords=''):

BIN
tracim/tracim/i18n/en/LC_MESSAGES/tracim.mo ファイルの表示


+ 110 - 69
tracim/tracim/i18n/en/LC_MESSAGES/tracim.po ファイルの表示

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

@@ -125,8 +125,8 @@ class Logger(object):
125 125
     def debug(self, instance_or_class, message):
126 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 131
     def info(self, instance_or_class, message):
132 132
         self._logger.info(Logger.TPL.format(cls=self._txt(instance_or_class), msg=message))

+ 7 - 2
tracim/tracim/lib/content.py ファイルの表示

@@ -222,7 +222,7 @@ class ContentApi(object):
222 222
             result = result.filter(Content.type.in_(self.DISPLAYABLE_CONTENTS))
223 223
 
224 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 227
         # Security layer: if user provided, filter
228 228
         # with user workspaces privileges
@@ -735,7 +735,10 @@ class ContentApi(object):
735 735
         assert content_type is not None# DYN_REMOVE
736 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 743
         if content_type!=ContentType.Any:
741 744
             resultset = resultset.filter(Content.type==content_type)
@@ -773,6 +776,8 @@ class ContentApi(object):
773 776
 
774 777
         not_read_revisions = self._revisions_base_query(workspace) \
775 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 781
             .subquery()
777 782
 
778 783
         not_read_content_ids_query = DBSession.query(

+ 43 - 2
tracim/tracim/lib/daemons.py ファイルの表示

@@ -19,6 +19,7 @@ from tracim.lib.base import logger
19 19
 from tracim.lib.exceptions import AlreadyRunningDaemon
20 20
 
21 21
 from tracim.lib.utils import get_rq_queue
22
+from tracim.lib.email_fetcher import MailFetcher
22 23
 
23 24
 
24 25
 class DaemonsManager(object):
@@ -151,6 +152,46 @@ class Daemon(threading.Thread):
151 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 195
 class MailSenderDaemon(Daemon):
155 196
     # NOTE: use *args and **kwargs because parent __init__ use strange
156 197
     # * parameter
@@ -314,8 +355,8 @@ from tracim.lib.webdav.sql_domain_controller import TracimDomainController
314 355
 from inspect import isfunction
315 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 361
 DEFAULT_CONFIG_FILE = "wsgidav.conf"
321 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 ファイルの表示

@@ -4,6 +4,7 @@ import typing
4 4
 from email.message import Message
5 5
 from email.mime.multipart import MIMEMultipart
6 6
 from email.mime.text import MIMEText
7
+from email.utils import formataddr
7 8
 
8 9
 from mako.template import Template
9 10
 from tg.i18n import ugettext as _
@@ -156,11 +157,11 @@ class EmailManager(object):
156 157
             )
157 158
         message = MIMEMultipart('alternative')
158 159
         message['Subject'] = subject
159
-        message['From'] = '{0} <{1}>'.format(
160
+        message['From'] = formataddr((
160 161
             self._global_config.EMAIL_NOTIFICATION_FROM_DEFAULT_LABEL,
161 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 166
         text_template_file_path = self._global_config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_TEXT  # nopep8
166 167
         html_template_file_path = self._global_config.EMAIL_NOTIFICATION_CREATED_ACCOUNT_TEMPLATE_HTML  # nopep8

+ 434 - 0
tracim/tracim/lib/email_fetcher.py ファイルの表示

@@ -0,0 +1,434 @@
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 ファイルの表示


+ 211 - 0
tracim/tracim/lib/email_processing/checkers.py ファイルの表示

@@ -0,0 +1,211 @@
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 ファイルの表示

@@ -0,0 +1,116 @@
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 ファイルの表示

@@ -0,0 +1,128 @@
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 ファイルの表示

@@ -0,0 +1,66 @@
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 ファイルの表示

@@ -0,0 +1 @@
1
+ATTRS_WHITELIST = ['href']

+ 1 - 0
tracim/tracim/lib/email_processing/sanitizer_config/class_blacklist.py ファイルの表示

@@ -0,0 +1 @@
1
+CLASS_BLACKLIST =  []

+ 1 - 0
tracim/tracim/lib/email_processing/sanitizer_config/id_blacklist.py ファイルの表示

@@ -0,0 +1 @@
1
+ID_BLACKLIST = []

+ 1 - 0
tracim/tracim/lib/email_processing/sanitizer_config/tag_blacklist.py ファイルの表示

@@ -0,0 +1 @@
1
+TAG_BLACKLIST = ['script', 'style']

+ 16 - 0
tracim/tracim/lib/email_processing/sanitizer_config/tag_whitelist.py ファイルの表示

@@ -0,0 +1,16 @@
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 ファイルの表示

@@ -5,6 +5,7 @@ import typing
5 5
 from email.header import Header
6 6
 from email.mime.multipart import MIMEMultipart
7 7
 from email.mime.text import MIMEText
8
+from email.utils import formataddr
8 9
 
9 10
 from lxml.html.diff import htmldiff
10 11
 
@@ -207,10 +208,7 @@ class EmailNotifier(object):
207 208
         else:
208 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 213
     @staticmethod
216 214
     def log_notification(
@@ -269,8 +267,17 @@ class EmailNotifier(object):
269 267
 
270 268
         for role in notifiable_roles:
271 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 282
             #  INFO - D.A. - 2014-11-06
276 283
             # We do not use .format() here because the subject defined in the .ini file
@@ -282,12 +289,21 @@ class EmailNotifier(object):
282 289
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
283 290
             subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
284 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 296
             message = MIMEMultipart('alternative')
287 297
             message['Subject'] = subject
288 298
             message['From'] = self._get_sender(user)
289 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 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 ファイルの表示

@@ -3,6 +3,7 @@ import os
3 3
 import time
4 4
 import signal
5 5
 
6
+import tg
6 7
 from tg import config
7 8
 from tg import require
8 9
 from tg import response
@@ -150,12 +151,17 @@ def _lazy_ugettext(text: str):
150 151
     :return: lazyfied string or string
151 152
     """
152 153
     try:
153
-        # Test if context is available,
154
+        # Test if tg.translator is defined
155
+        #
154 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 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 165
         return text
160 166
 
161 167
 lazy_ugettext = lazify(_lazy_ugettext)

+ 6 - 4
tracim/tracim/lib/webdav/design.py ファイルの表示

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

+ 2 - 2
tracim/tracim/lib/workspace.py ファイルの表示

@@ -34,8 +34,8 @@ class WorkspaceApi(object):
34 34
 
35 35
         return DBSession.query(Workspace).\
36 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 40
     def create_workspace(
41 41
             self,

+ 10 - 0
tracim/tracim/model/auth.py ファイルの表示

@@ -280,6 +280,16 @@ class User(DeclarativeBase):
280 280
         from tracim.model.data import UserRoleInWorkspace
281 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 293
     def ensure_auth_token(self) -> None:
284 294
         """
285 295
         Create auth_token if None, regenerate auth_token if too much old.

+ 3 - 13
tracim/tracim/model/data.py ファイルの表示

@@ -1111,16 +1111,6 @@ class Content(DeclarativeBase):
1111 1111
         self._properties = json.dumps(properties_struct)
1112 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 1114
     def created_as_delta(self, delta_from_datetime:datetime=None):
1125 1115
         if not delta_from_datetime:
1126 1116
             delta_from_datetime = datetime.utcnow()
@@ -1213,13 +1203,13 @@ class Content(DeclarativeBase):
1213 1203
         return last_comment
1214 1204
 
1215 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 1207
         rev_ids.sort()
1218 1208
 
1219 1209
         if len(rev_ids)>=2:
1220 1210
             revision_rev_id = rev_ids[-2]
1221 1211
 
1222
-            for revision in self.clean_revisions:
1212
+            for revision in self.revisions:
1223 1213
                 if revision.revision_id == revision_rev_id:
1224 1214
                     return revision
1225 1215
 
@@ -1248,7 +1238,7 @@ class Content(DeclarativeBase):
1248 1238
         events = []
1249 1239
         for comment in self.get_comments():
1250 1240
             events.append(VirtualEvent.create_from_content(comment))
1251
-        for revision in self.clean_revisions:
1241
+        for revision in self.revisions:
1252 1242
             events.append(VirtualEvent.create_from_content_revision(revision))
1253 1243
 
1254 1244
         sorted_events = sorted(events,

+ 2 - 1
tracim/tracim/model/serializers.py ファイルの表示

@@ -914,7 +914,7 @@ def serialize_user_for_user(user: User, context: Context):
914 914
     result['id'] = user.user_id
915 915
     result['name'] = user.get_display_name()
916 916
     result['email'] = user.email
917
-    result['roles'] = context.toDict(user.roles)
917
+    result['roles'] = context.toDict(user.get_active_roles())
918 918
     result['enabled'] = user.is_active
919 919
     result['profile'] = user.profile
920 920
     result['calendar_url'] = user.calendar_url
@@ -996,6 +996,7 @@ def serialize_workspace_in_list_for_one_user(workspace: Workspace, context: Cont
996 996
     result = DictLikeClass()
997 997
     result['id'] = workspace.workspace_id
998 998
     result['name'] = workspace.label
999
+    result['is_deleted'] = workspace.is_deleted
999 1000
 
1000 1001
     return result
1001 1002
 

+ 1 - 1
tracim/tracim/templates/admin/user_getall.mak ファイルの表示

@@ -17,7 +17,7 @@
17 17
 <%def name="TITLE_ROW()">
18 18
     <div class="row-fluid">
19 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 21
         </div>
22 22
     </div>
23 23
 </%def>

+ 1 - 1
tracim/tracim/templates/admin/workspace_getall.mak ファイルの表示

@@ -14,7 +14,7 @@
14 14
 </%def>
15 15
 
16 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 18
 </%def>
19 19
 
20 20
 <div class="workspace__wrapper">

+ 1 - 1
tracim/tracim/templates/file/edit.mak ファイルの表示

@@ -14,7 +14,7 @@
14 14
                 <input name="label" type="text" class="form-control" id="file-title" placeholder="${_('Title')}" value="${file.label}">
15 15
             </div>
16 16
             <div class="form-group">
17
-                <label for="file-content">${_('Content')}</label>
17
+                <label for="file-content">${_('Description')}</label>
18 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 19
             </div>
20 20
         </div>

+ 3 - 3
tracim/tracim/templates/file/getone.mak ファイルの表示

@@ -66,14 +66,14 @@
66 66
     <div class="alert alert-info" role="alert">
67 67
         <p>
68 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 70
         </p>
71 71
     </div>
72 72
     % elif (result.file.is_deleted) :
73 73
     <div class="alert alert-info" role="alert">
74 74
         <p>
75 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 77
         </p>
78 78
     </div>
79 79
     % endif
@@ -124,7 +124,7 @@
124 124
                     selectedRevision: '${result.file.selected_revision}',
125 125
                     weight: '${h.user_friendly_file_size(result.file.file.size)}',
126 126
                     height: '300',
127
-                    modifiedAt: '${h.format_short(created_localized)|n}',
127
+                    modifiedAt: '${h.format_short(updated_localized)|n}',
128 128
                     owner: '${result.file.owner.name}',
129 129
                     sourceLink: '${download_url}',
130 130
                     pdfAvailable: ${pdf_available}

+ 2 - 2
tracim/tracim/templates/folder/getone.mak ファイルの表示

@@ -64,14 +64,14 @@
64 64
     <div class="alert alert-info" role="alert">
65 65
         <p>
66 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 68
         </p>
69 69
     </div>
70 70
     % elif (result.folder.is_deleted) :
71 71
     <div class="alert alert-info" role="alert">
72 72
         <p>
73 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 75
         </p>
76 76
     </div>
77 77
     % endif

+ 3 - 1
tracim/tracim/templates/home.mak ファイルの表示

@@ -137,7 +137,9 @@
137 137
                             </tr>
138 138
                         </thead>
139 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 143
                         % endfor
142 144
                     </table>
143 145
                 % endif

+ 2 - 2
tracim/tracim/templates/page/getone.mak ファイルの表示

@@ -64,14 +64,14 @@
64 64
     <div class="alert alert-info" role="alert">
65 65
         <p>
66 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 68
         </p>
69 69
     </div>
70 70
     % elif (result.page.is_deleted) :
71 71
     <div class="alert alert-info" role="alert">
72 72
         <p>
73 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 75
         </p>
76 76
     </div>
77 77
     % endif

+ 2 - 2
tracim/tracim/templates/thread/getone.mak ファイルの表示

@@ -66,7 +66,7 @@
66 66
         <div class="">
67 67
             <p>
68 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 70
             </p>
71 71
         </div>
72 72
     </div>
@@ -75,7 +75,7 @@
75 75
         <div class="">
76 76
             <p>
77 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 79
             </p>
80 80
         </div>
81 81
     </div>

+ 1 - 2
tracim/tracim/templates/widgets/left_menu.mak ファイルの表示

@@ -2,7 +2,7 @@
2 2
 <%namespace name="ICON" file="tracim.templates.widgets.icon"/>
3 3
 
4 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 6
     <div id="sidebarleft_menu"></div>
7 7
     <script src="${tg.url('/assets/js/sidebarleft.js')}"></script>
8 8
     <script>
@@ -12,4 +12,3 @@
12 12
       })()
13 13
     </script>
14 14
 </%def>
15
-

+ 4 - 4
tracim/tracim/templates/widgets/table_row.mak ファイルの表示

@@ -65,9 +65,9 @@
65 65
         <!--td class="folder__content__list__type">
66 66
             <span class="${content.type.color}">
67 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 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 71
                 % endif
72 72
                 ${content.type.label}
73 73
             </span>
@@ -95,9 +95,9 @@
95 95
                     <span class="t-less-visible">
96 96
                       ${content.status.label}
97 97
                       % if (content.is_archived) :
98
-                          - Archivé
98
+                          - ${_('archived')}
99 99
                       % elif (content.is_deleted) :
100
-                          - Supprimé
100
+                          - ${_('deleted')}
101 101
                       % endif
102 102
                     </span>
103 103
                 </a>

+ 3 - 3
tracim/tracim/tests/functional/test_ldap_restrictions.py ファイルの表示

@@ -26,7 +26,7 @@ class TestAuthentication(LDAPTest, TracimTestController):
26 26
         home = self.app.get('/home/',)
27 27
 
28 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 31
         # If we force passwd update, we got 403
32 32
         try_post_passwd = self.app.post(
@@ -51,12 +51,12 @@ class TestAuthentication(LDAPTest, TracimTestController):
51 51
         edit = self.app.get('/user/5/edit')
52 52
 
53 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 55
         ok_('readonly' in email_input.attrs)
56 56
         eq_(email_input.attrs['readonly'], "readonly")
57 57
 
58 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 60
         ok_('readonly' not in name_input.attrs)
61 61
 
62 62
         # If we force edit of user, "email" field will be not updated

+ 1 - 1
tracim/tracim/tests/functional/test_root.py ファイルの表示

@@ -30,7 +30,7 @@ class TestRootController(TestController):
30 30
         msg = 'copyright &copy; 2013 - {} tracim project.'.format(h.current_year())
31 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 34
         print('FORMS = ',forms)
35 35
         eq_(1, len(forms))
36 36
         eq_('w-login-form', forms[0].get('id'))

+ 759 - 0
tracim/tracim/tests/library/test_email_body_parser.py ファイルの表示

@@ -0,0 +1,759 @@
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 ファイルの表示

@@ -0,0 +1,11 @@
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 ファイルの表示

@@ -1,20 +0,0 @@
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"