Browse Source

Merge pull request #498 from inkhey/feature/replywithmail

Bastien Sevajol 6 years ago
parent
commit
62ff6eed87
No account linked to committer's email

+ 2 - 0
install/requirements.txt View File

65
 typing==3.5.3.0
65
 typing==3.5.3.0
66
 rq==0.7.1
66
 rq==0.7.1
67
 click==6.7
67
 click==6.7
68
+markdown==2.6.9
69
+email_reply_parser==0.5.9

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

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

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

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

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

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

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

22
 from tracim.controllers.previews import PreviewsController
22
 from tracim.controllers.previews import PreviewsController
23
 from tracim.controllers.user import UserRestController
23
 from tracim.controllers.user import UserRestController
24
 from tracim.controllers.workspace import UserWorkspaceRestController
24
 from tracim.controllers.workspace import UserWorkspaceRestController
25
+from tracim.controllers.events import EventRestController
25
 from tracim.lib import CST
26
 from tracim.lib import CST
26
 from tracim.lib.base import logger
27
 from tracim.lib.base import logger
27
 from tracim.lib.content import ContentApi
28
 from tracim.lib.content import ContentApi
61
     previews = PreviewsController()
62
     previews = PreviewsController()
62
 
63
 
63
     content = ContentController()
64
     content = ContentController()
64
-
65
+    events = EventRestController()
65
     # api
66
     # api
66
     api = APIController()
67
     api = APIController()
67
 
68
 

+ 38 - 0
tracim/tracim/lib/daemons.py View File

19
 from tracim.lib.exceptions import AlreadyRunningDaemon
19
 from tracim.lib.exceptions import AlreadyRunningDaemon
20
 
20
 
21
 from tracim.lib.utils import get_rq_queue
21
 from tracim.lib.utils import get_rq_queue
22
+from tracim.lib.email_fetcher import MailFetcher
22
 
23
 
23
 
24
 
24
 class DaemonsManager(object):
25
 class DaemonsManager(object):
151
         raise NotImplementedError()
152
         raise NotImplementedError()
152
 
153
 
153
 
154
 
155
+class MailFetcherDaemon(Daemon):
156
+    """
157
+    Thread containing a daemon who fetch new mail from a mailbox and
158
+    send http request to a tracim endpoint to handle them.
159
+    """
160
+
161
+    def __init__(self, *args, **kwargs) -> None:
162
+        super().__init__(*args, **kwargs)
163
+        self._fetcher = None  # type: MailFetcher
164
+        self.ok = True
165
+
166
+    def run(self) -> None:
167
+        from tracim.config.app_cfg import CFG
168
+        cfg = CFG.get_instance()
169
+        self._fetcher = MailFetcher(
170
+            host=cfg.EMAIL_REPLY_IMAP_SERVER,
171
+            port=cfg.EMAIL_REPLY_IMAP_PORT,
172
+            user=cfg.EMAIL_REPLY_IMAP_USER,
173
+            password=cfg.EMAIL_REPLY_IMAP_PASSWORD,
174
+            use_ssl=cfg.EMAIL_REPLY_IMAP_USE_SSL,
175
+            folder=cfg.EMAIL_REPLY_IMAP_FOLDER,
176
+            delay=cfg.EMAIL_REPLY_CHECK_HEARTBEAT,
177
+            # FIXME - G.M - 2017-11-15 - proper tracim url formatting
178
+            endpoint=cfg.WEBSITE_BASE_URL + "/events",
179
+            token=cfg.EMAIL_REPLY_TOKEN,
180
+        )
181
+        self._fetcher.run()
182
+
183
+    def stop(self) -> None:
184
+        if self._fetcher:
185
+            self._fetcher.stop()
186
+
187
+    def append_thread_callback(self, callback: collections.Callable) -> None:
188
+        logger.warning('MailFetcherDaemon not implement append_thread_calback')
189
+        pass
190
+
191
+
154
 class MailSenderDaemon(Daemon):
192
 class MailSenderDaemon(Daemon):
155
     # NOTE: use *args and **kwargs because parent __init__ use strange
193
     # NOTE: use *args and **kwargs because parent __init__ use strange
156
     # * parameter
194
     # * parameter

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

1
+# -*- coding: utf-8 -*-
2
+
3
+import sys
4
+import time
5
+import imaplib
6
+import datetime
7
+import json
8
+import typing
9
+from email.message import Message
10
+from email.header import Header, decode_header, make_header
11
+from email.utils import parseaddr, parsedate_tz, mktime_tz
12
+from email import message_from_bytes
13
+
14
+import markdown
15
+import requests
16
+from bs4 import BeautifulSoup, Tag
17
+from email_reply_parser import EmailReplyParser
18
+
19
+from tracim.lib.base import logger
20
+
21
+TRACIM_SPECIAL_KEY_HEADER = 'X-Tracim-Key'
22
+# TODO BS 20171124: Think about replace thin dict config by object
23
+BEAUTIFULSOUP_HTML_BODY_PARSE_CONFIG = {
24
+    'tag_blacklist': ['script', 'style', 'blockquote'],
25
+    'class_blacklist': ['moz-cite-prefix', 'gmail_extra', 'gmail_quote',
26
+                        'yahoo_quoted'],
27
+    'id_blacklist': ['reply-intro'],
28
+    'tag_whitelist': ['a', 'b', 'strong', 'i', 'br', 'ul', 'li', 'ol',
29
+                      'em', 'i', 'u',
30
+                      'thead', 'tr', 'td', 'tbody', 'table', 'p', 'pre'],
31
+    'attrs_whitelist': ['href'],
32
+}
33
+CONTENT_TYPE_TEXT_PLAIN = 'text/plain'
34
+CONTENT_TYPE_TEXT_HTML = 'text/html'
35
+
36
+
37
+class DecodedMail(object):
38
+    def __init__(self, message: Message) -> None:
39
+        self._message = message
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(self) -> typing.Optional[str]:
64
+        body_part = self._get_mime_body_message()
65
+        body = None
66
+        if body_part:
67
+            charset = body_part.get_content_charset('iso-8859-1')
68
+            content_type = body_part.get_content_type()
69
+            if content_type == CONTENT_TYPE_TEXT_PLAIN:
70
+                txt_body = body_part.get_payload(decode=True).decode(
71
+                    charset)
72
+                body = DecodedMail._parse_txt_body(txt_body)
73
+
74
+            elif content_type == CONTENT_TYPE_TEXT_HTML:
75
+                html_body = body_part.get_payload(decode=True).decode(
76
+                    charset)
77
+                body = DecodedMail._parse_html_body(html_body)
78
+
79
+        return body
80
+
81
+    @classmethod
82
+    def _parse_txt_body(cls, txt_body: str) -> str:
83
+        txt_body = EmailReplyParser.parse_reply(txt_body)
84
+        html_body = markdown.markdown(txt_body)
85
+        body = DecodedMail._parse_html_body(html_body)
86
+        return body
87
+
88
+    @classmethod
89
+    def _parse_html_body(cls, html_body: str) -> str:
90
+        soup = BeautifulSoup(html_body, 'html.parser')
91
+        config = BEAUTIFULSOUP_HTML_BODY_PARSE_CONFIG
92
+        for tag in soup.findAll():
93
+            if DecodedMail._tag_to_extract(tag):
94
+                tag.extract()
95
+            elif tag.name.lower() in config['tag_whitelist']:
96
+                attrs = dict(tag.attrs)
97
+                for attr in attrs:
98
+                    if attr not in config['attrs_whitelist']:
99
+                        del tag.attrs[attr]
100
+            else:
101
+                tag.unwrap()
102
+        return str(soup)
103
+
104
+    @classmethod
105
+    def _tag_to_extract(cls, tag: Tag) -> bool:
106
+        config = BEAUTIFULSOUP_HTML_BODY_PARSE_CONFIG
107
+        if tag.name.lower() in config['tag_blacklist']:
108
+            return True
109
+        if 'class' in tag.attrs:
110
+            for elem in config['class_blacklist']:
111
+                if elem in tag.attrs['class']:
112
+                    return True
113
+        if 'id' in tag.attrs:
114
+            for elem in config['id_blacklist']:
115
+                if elem in tag.attrs['id']:
116
+                    return True
117
+        return False
118
+
119
+    def _get_mime_body_message(self) -> typing.Optional[Message]:
120
+        # TODO - G.M - 2017-11-16 - Use stdlib msg.get_body feature for py3.6+
121
+        part = None
122
+        # Check for html
123
+        for part in self._message.walk():
124
+            content_type = part.get_content_type()
125
+            content_dispo = str(part.get('Content-Disposition'))
126
+            if content_type == CONTENT_TYPE_TEXT_HTML \
127
+                    and 'attachment' not in content_dispo:
128
+                return part
129
+        # check for plain text
130
+        for part in self._message.walk():
131
+            content_type = part.get_content_type()
132
+            content_dispo = str(part.get('Content-Disposition'))
133
+            if content_type == CONTENT_TYPE_TEXT_PLAIN \
134
+                    and 'attachment' not in content_dispo:
135
+                return part
136
+        return part
137
+
138
+    def get_key(self) -> typing.Optional[str]:
139
+
140
+        """
141
+        key is the string contain in some mail header we need to retrieve.
142
+        First try checking special header, them check 'to' header
143
+        and finally check first(oldest) mail-id of 'references' header
144
+        """
145
+        first_ref = self.get_first_ref()
146
+        to_address = self.get_to_address()
147
+        special_key = self.get_special_key()
148
+
149
+        if special_key:
150
+            return special_key
151
+        if to_address:
152
+            return DecodedMail.find_key_from_mail_address(to_address)
153
+        if first_ref:
154
+            return DecodedMail.find_key_from_mail_address(first_ref)
155
+
156
+        return None
157
+
158
+    @classmethod
159
+    def find_key_from_mail_address(
160
+        cls,
161
+        mail_address: str,
162
+    ) -> typing.Optional[str]:
163
+        """ Parse mail_adress-like string
164
+        to retrieve key.
165
+
166
+        :param mail_address: user+key@something like string
167
+        :return: key
168
+        """
169
+        username = mail_address.split('@')[0]
170
+        username_data = username.split('+')
171
+        if len(username_data) == 2:
172
+            return username_data[1]
173
+        return None
174
+
175
+
176
+class MailFetcher(object):
177
+    def __init__(
178
+        self,
179
+        host: str,
180
+        port: str,
181
+        user: str,
182
+        password: str,
183
+        use_ssl: bool,
184
+        folder: str,
185
+        delay: int,
186
+        endpoint: str,
187
+        token: str,
188
+    ) -> None:
189
+        """
190
+        Fetch mail from a mailbox folder through IMAP and add their content to
191
+        Tracim through http according to mail Headers.
192
+        Fetch is regular.
193
+        :param host: imap server hostname
194
+        :param port: imap connection port
195
+        :param user: user login of mailbox
196
+        :param password: user password of mailbox
197
+        :param use_ssl: use imap over ssl connection
198
+        :param folder: mail folder where new mail are fetched
199
+        :param delay: seconds to wait before fetching new mail again
200
+        :param endpoint: tracim http endpoint where decoded mail are send.
201
+        :param token: token to authenticate http connexion
202
+        """
203
+        self._connection = None
204
+        self.host = host
205
+        self.port = port
206
+        self.user = user
207
+        self.password = password
208
+        self.use_ssl = use_ssl
209
+        self.folder = folder
210
+        self.delay = delay
211
+        self.endpoint = endpoint
212
+        self.token = token
213
+
214
+        self._is_active = True
215
+
216
+    def run(self) -> None:
217
+        while self._is_active:
218
+            time.sleep(self.delay)
219
+            try:
220
+                self._connect()
221
+                messages = self._fetch()
222
+                # TODO - G.M -  2017-11-22 retry sending unsended mail
223
+                # These mails are return by _notify_tracim, flag them with "unseen"
224
+                # or store them until new _notify_tracim call
225
+                cleaned_mails = [DecodedMail(msg) for msg in messages]
226
+                self._notify_tracim(cleaned_mails)
227
+                self._disconnect()
228
+            except Exception as e:
229
+                # TODO - G.M - 2017-11-23 - Identify possible exceptions
230
+                log = 'IMAP error: {}'
231
+                logger.warning(self, log.format(e.__str__()))
232
+
233
+    def stop(self) -> None:
234
+        self._is_active = False
235
+
236
+    def _connect(self) -> None:
237
+        # TODO - G.M - 2017-11-15 Verify connection/disconnection
238
+        # Are old connexion properly close this way ?
239
+        if self._connection:
240
+            self._disconnect()
241
+        # TODO - G.M - 2017-11-23 Support for predefined SSLContext ?
242
+        # without ssl_context param, tracim use default security configuration
243
+        # which is great in most case.
244
+        if self.use_ssl:
245
+            self._connection = imaplib.IMAP4_SSL(self.host, self.port)
246
+        else:
247
+            self._connection = imaplib.IMAP4(self.host, self.port)
248
+
249
+        try:
250
+            self._connection.login(self.user, self.password)
251
+        except Exception as e:
252
+            log = 'IMAP login error: {}'
253
+            logger.warning(self, log.format(e.__str__()))
254
+
255
+    def _disconnect(self) -> None:
256
+        if self._connection:
257
+            self._connection.close()
258
+            self._connection.logout()
259
+            self._connection = None
260
+
261
+    def _fetch(self) -> typing.List[Message]:
262
+        """
263
+        Get news message from mailbox
264
+        :return: list of new mails
265
+        """
266
+        messages = []
267
+        # select mailbox
268
+        rv, data = self._connection.select(self.folder)
269
+        if rv == 'OK':
270
+            # get mails
271
+            # TODO - G.M -  2017-11-15 Which files to select as new file ?
272
+            # Unseen file or All file from a directory (old one should be
273
+            #  moved/ deleted from mailbox during this process) ?
274
+            rv, data = self._connection.search(None, "(UNSEEN)")
275
+            if rv == 'OK':
276
+                # get mail content
277
+                for num in data[0].split():
278
+                    # INFO - G.M - 2017-11-23 - Fetch (RFC288) to retrieve all
279
+                    # complete mails see example : https://docs.python.org/fr/3.5/library/imaplib.html#imap4-example .  # nopep8
280
+                    # Be careful, This method remove also mails from Unseen
281
+                    # mails
282
+                    rv, data = self._connection.fetch(num, '(RFC822)')
283
+                    if rv == 'OK':
284
+                        msg = message_from_bytes(data[0][1])
285
+                        messages.append(msg)
286
+                    else:
287
+                        log = 'IMAP : Unable to get mail : {}'
288
+                        logger.debug(self, log.format(str(rv)))
289
+            else:
290
+                # FIXME : Distinct error from empty mailbox ?
291
+                pass
292
+        else:
293
+            log = 'IMAP : Unable to open mailbox : {}'
294
+            logger.debug(self, log.format(str(rv)))
295
+        return messages
296
+
297
+    def _notify_tracim(
298
+        self,
299
+        mails: typing.List[DecodedMail],
300
+    ) -> typing.List[DecodedMail]:
301
+        """
302
+        Send http request to tracim endpoint
303
+        :param mails: list of mails to send
304
+        :return: unsended mails
305
+        """
306
+        unsended_mails = []
307
+        # TODO BS 20171124: Look around mail.get_from_address(), mail.get_key()
308
+        # , mail.get_body() etc ... for raise InvalidEmailError if missing
309
+        #  required informations (actually get_from_address raise IndexError
310
+        #  if no from address for example) and catch it here
311
+        while mails:
312
+            mail = mails.pop()
313
+            msg = {'token': self.token,
314
+                   'user_mail': mail.get_from_address(),
315
+                   'content_id': mail.get_key(),
316
+                   'payload': {
317
+                       'content': mail.get_body(),
318
+                   }}
319
+            try:
320
+                r = requests.post(self.endpoint, json=msg)
321
+                if r.status_code not in [200, 204]:
322
+                    log = 'bad status code response when sending mail to tracim: {}'  # nopep8
323
+                    logger.error(self, log.format(str(r.status_code)))
324
+            # TODO - G.M - Verify exception correctly works
325
+            except requests.exceptions.Timeout as e:
326
+                log = 'Timeout error to transmit fetched mail to tracim : {}'
327
+                logger.error(self, log.format(str(e)))
328
+                unsended_mails.append(mail)
329
+                break
330
+            except requests.exceptions.RequestException as e:
331
+                log = 'Fail to transmit fetched mail to tracim : {}'
332
+                logger.error(self, log.format(str(e)))
333
+                break
334
+
335
+        return unsended_mails

+ 19 - 1
tracim/tracim/lib/notifications.py View File

268
         for role in notifiable_roles:
268
         for role in notifiable_roles:
269
             logger.info(self, 'Sending email to {}'.format(role.user.email))
269
             logger.info(self, 'Sending email to {}'.format(role.user.email))
270
             to_addr = formataddr((role.user.display_name, 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
+            )
271
 
277
 
278
+            reference_addr = self._global_config.EMAIL_NOTIFICATION_REFERENCES_EMAIL.replace( #nopep8
279
+                '{content_id}',str(content.content_id)
280
+             )
272
             #
281
             #
273
             #  INFO - D.A. - 2014-11-06
282
             #  INFO - D.A. - 2014-11-06
274
             # We do not use .format() here because the subject defined in the .ini file
283
             # We do not use .format() here because the subject defined in the .ini file
280
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
289
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
281
             subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
290
             subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
282
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
291
             subject = subject.replace(EST.CONTENT_STATUS_LABEL, main_content.get_status().label.__str__())
292
+            reply_to_label = l_('{username} & all members of {workspace}').format(
293
+                username=user.display_name,
294
+                workspace=main_content.workspace.label)
283
 
295
 
284
             message = MIMEMultipart('alternative')
296
             message = MIMEMultipart('alternative')
285
             message['Subject'] = subject
297
             message['Subject'] = subject
286
             message['From'] = self._get_sender(user)
298
             message['From'] = self._get_sender(user)
287
             message['To'] = to_addr
299
             message['To'] = to_addr
288
-
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))
289
             body_text = self._build_email_body(self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
307
             body_text = self._build_email_body(self._global_config.EMAIL_NOTIFICATION_CONTENT_UPDATE_TEMPLATE_TEXT, role, content, user)
290
 
308
 
291
 
309
 

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

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

tracim/app.wsgi → tracim/wsgi.py View File