Explorar el Código

Merge pull request #498 from inkhey/feature/replywithmail

Bastien Sevajol hace 6 años
padre
commit
62ff6eed87
No account linked to committer's email

+ 2 - 0
install/requirements.txt Ver fichero

@@ -65,3 +65,5 @@ 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

+ 15 - 0
tracim/development.ini.base Ver fichero

@@ -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,19 @@ 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
+
215 230
 ## Radical (CalDav server) configuration
216 231
 # radicale.server.host = 0.0.0.0
217 232
 # radicale.server.port = 5232

+ 41 - 0
tracim/tracim/config/app_cfg.py Ver fichero

@@ -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,37 @@ 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
+
347 388
         self.TRACKER_JS_PATH = tg.config.get(
348 389
             'js_tracker_path',
349 390
         )

+ 100 - 0
tracim/tracim/controllers/events.py Ver fichero

@@ -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 - 1
tracim/tracim/controllers/root.py Ver fichero

@@ -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
 

+ 38 - 0
tracim/tracim/lib/daemons.py Ver fichero

@@ -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,43 @@ 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
+        )
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 192
 class MailSenderDaemon(Daemon):
155 193
     # NOTE: use *args and **kwargs because parent __init__ use strange
156 194
     # * parameter

+ 335 - 0
tracim/tracim/lib/email_fetcher.py Ver fichero

@@ -0,0 +1,335 @@
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 Ver fichero

@@ -268,7 +268,16 @@ class EmailNotifier(object):
268 268
         for role in notifiable_roles:
269 269
             logger.info(self, 'Sending email to {}'.format(role.user.email))
270 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 282
             #  INFO - D.A. - 2014-11-06
274 283
             # We do not use .format() here because the subject defined in the .ini file
@@ -280,12 +289,21 @@ class EmailNotifier(object):
280 289
             subject = subject.replace(EST.WORKSPACE_LABEL, main_content.workspace.label.__str__())
281 290
             subject = subject.replace(EST.CONTENT_LABEL, main_content.label.__str__())
282 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 296
             message = MIMEMultipart('alternative')
285 297
             message['Subject'] = subject
286 298
             message['From'] = self._get_sender(user)
287 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 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 Ver fichero

@@ -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'

tracim/app.wsgi → tracim/wsgi.py Ver fichero