|
@@ -1,9 +1,11 @@
|
1
|
1
|
# -*- coding: utf-8 -*-
|
2
|
2
|
|
3
|
3
|
import time
|
4
|
|
-import imaplib
|
5
|
4
|
import json
|
6
|
5
|
import typing
|
|
6
|
+import socket
|
|
7
|
+import ssl
|
|
8
|
+
|
7
|
9
|
from email import message_from_bytes
|
8
|
10
|
from email.header import decode_header
|
9
|
11
|
from email.header import make_header
|
|
@@ -13,6 +15,8 @@ from email.utils import parseaddr
|
13
|
15
|
import filelock
|
14
|
16
|
import markdown
|
15
|
17
|
import requests
|
|
18
|
+import imapclient
|
|
19
|
+
|
16
|
20
|
from email_reply_parser import EmailReplyParser
|
17
|
21
|
from tracim.lib.base import logger
|
18
|
22
|
from tracim.lib.email_processing.parser import ParsedHTMLMail
|
|
@@ -22,9 +26,13 @@ TRACIM_SPECIAL_KEY_HEADER = 'X-Tracim-Key'
|
22
|
26
|
CONTENT_TYPE_TEXT_PLAIN = 'text/plain'
|
23
|
27
|
CONTENT_TYPE_TEXT_HTML = 'text/html'
|
24
|
28
|
|
25
|
|
-IMAP_SEEN_FLAG = '\\Seen'
|
26
|
|
-IMAP_CHECKED_FLAG = '\\Flagged'
|
|
29
|
+IMAP_SEEN_FLAG = imapclient.SEEN
|
|
30
|
+IMAP_CHECKED_FLAG = imapclient.FLAGGED
|
|
31
|
+
|
27
|
32
|
MAIL_FETCHER_FILELOCK_TIMEOUT = 10
|
|
33
|
+MAIL_FETCHER_CONNECTION_TIMEOUT = 60*3
|
|
34
|
+MAIL_FETCHER_IDLE_RESPONSE_TIMEOUT = 60*9 # this should be not more
|
|
35
|
+# that 29 minutes according to rfc2177.(server wait 30min by default)
|
28
|
36
|
|
29
|
37
|
|
30
|
38
|
class MessageContainer(object):
|
|
@@ -153,7 +161,9 @@ class MailFetcher(object):
|
153
|
161
|
password: str,
|
154
|
162
|
use_ssl: bool,
|
155
|
163
|
folder: str,
|
156
|
|
- delay: int,
|
|
164
|
+ use_idle: bool,
|
|
165
|
+ connection_max_lifetime: int,
|
|
166
|
+ heartbeat: int,
|
157
|
167
|
endpoint: str,
|
158
|
168
|
token: str,
|
159
|
169
|
use_html_parsing: bool,
|
|
@@ -170,20 +180,25 @@ class MailFetcher(object):
|
170
|
180
|
:param password: user password of mailbox
|
171
|
181
|
:param use_ssl: use imap over ssl connection
|
172
|
182
|
:param folder: mail folder where new mail are fetched
|
173
|
|
- :param delay: seconds to wait before fetching new mail again
|
|
183
|
+ :param use_idle: use IMAP IDLE(server notification) when available
|
|
184
|
+ :param heartbeat: seconds to wait before fetching new mail again
|
|
185
|
+ :param connection_max_lifetime: maximum duration allowed for a
|
|
186
|
+ connection . connection are automatically renew when their
|
|
187
|
+ lifetime excess this duration.
|
174
|
188
|
:param endpoint: tracim http endpoint where decoded mail are send.
|
175
|
189
|
:param token: token to authenticate http connexion
|
176
|
190
|
:param use_html_parsing: parse html mail
|
177
|
191
|
:param use_txt_parsing: parse txt mail
|
178
|
192
|
"""
|
179
|
|
- self._connection = None
|
180
|
193
|
self.host = host
|
181
|
194
|
self.port = port
|
182
|
195
|
self.user = user
|
183
|
196
|
self.password = password
|
184
|
197
|
self.use_ssl = use_ssl
|
185
|
198
|
self.folder = folder
|
186
|
|
- self.delay = delay
|
|
199
|
+ self.heartbeat = heartbeat
|
|
200
|
+ self.use_idle = use_idle
|
|
201
|
+ self.connection_max_lifetime = connection_max_lifetime
|
187
|
202
|
self.endpoint = endpoint
|
188
|
203
|
self.token = token
|
189
|
204
|
self.use_html_parsing = use_html_parsing
|
|
@@ -194,150 +209,201 @@ class MailFetcher(object):
|
194
|
209
|
def run(self) -> None:
|
195
|
210
|
logger.info(self, 'Starting MailFetcher')
|
196
|
211
|
while self._is_active:
|
197
|
|
- logger.debug(self, 'sleep for {}'.format(self.delay))
|
198
|
|
- time.sleep(self.delay)
|
|
212
|
+ imapc = None
|
|
213
|
+ sleep_after_connection = True
|
199
|
214
|
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()
|
|
215
|
+ imapc = imapclient.IMAPClient(
|
|
216
|
+ self.host,
|
|
217
|
+ self.port,
|
|
218
|
+ ssl=self.use_ssl,
|
|
219
|
+ timeout=MAIL_FETCHER_CONNECTION_TIMEOUT
|
|
220
|
+ )
|
|
221
|
+ imapc.login(self.user, self.password)
|
|
222
|
+
|
|
223
|
+ logger.debug(self, 'Select folder {}'.format(
|
|
224
|
+ self.folder,
|
|
225
|
+ ))
|
|
226
|
+ imapc.select_folder(self.folder)
|
|
227
|
+
|
|
228
|
+ # force renew connection when deadline is reached
|
|
229
|
+ deadline = time.time() + self.connection_max_lifetime
|
|
230
|
+ while True:
|
|
231
|
+ if not self._is_active:
|
|
232
|
+ logger.warning(self, 'Mail Fetcher process aborted')
|
|
233
|
+ sleep_after_connection = False
|
|
234
|
+ break
|
|
235
|
+
|
|
236
|
+ if time.time() > deadline:
|
|
237
|
+ logger.debug(
|
|
238
|
+ self,
|
|
239
|
+ "MailFetcher Connection Lifetime limit excess"
|
|
240
|
+ ", Try Re-new connection")
|
|
241
|
+ sleep_after_connection = False
|
|
242
|
+ break
|
|
243
|
+
|
|
244
|
+ # check for new mails
|
|
245
|
+ self._check_mail(imapc)
|
|
246
|
+
|
|
247
|
+ if self.use_idle and imapc.has_capability('IDLE'):
|
|
248
|
+ # IDLE_mode wait until event from server
|
|
249
|
+ logger.debug(self, 'wail for event(IDLE)')
|
|
250
|
+ imapc.idle()
|
|
251
|
+ imapc.idle_check(
|
|
252
|
+ timeout=MAIL_FETCHER_IDLE_RESPONSE_TIMEOUT
|
|
253
|
+ )
|
|
254
|
+ imapc.idle_done()
|
|
255
|
+ else:
|
|
256
|
+ if self.use_idle and not imapc.has_capability('IDLE'):
|
|
257
|
+ log = 'IDLE mode activated but server do not' \
|
|
258
|
+ 'support it, use polling instead.'
|
|
259
|
+ logger.warning(self, log)
|
|
260
|
+ # normal polling mode : sleep a define duration
|
|
261
|
+ logger.debug(self,
|
|
262
|
+ 'sleep for {}'.format(self.heartbeat))
|
|
263
|
+ time.sleep(self.heartbeat)
|
|
264
|
+
|
|
265
|
+ # Socket
|
|
266
|
+ except (socket.error,
|
|
267
|
+ socket.gaierror,
|
|
268
|
+ socket.herror) as e:
|
|
269
|
+ log = 'Socket fail with IMAP connection {}'
|
|
270
|
+ logger.error(self, log.format(e.__str__()))
|
|
271
|
+
|
|
272
|
+ except socket.timeout as e:
|
|
273
|
+ log = 'Socket timeout on IMAP connection {}'
|
|
274
|
+ logger.error(self, log.format(e.__str__()))
|
|
275
|
+
|
|
276
|
+ # SSL
|
|
277
|
+ except ssl.SSLError as e:
|
|
278
|
+ log = 'SSL error on IMAP connection'
|
|
279
|
+ logger.error(self, log.format(e.__str__()))
|
|
280
|
+
|
|
281
|
+ except ssl.CertificateError as e:
|
|
282
|
+ log = 'SSL Certificate verification failed on IMAP connection'
|
|
283
|
+ logger.error(self, log.format(e.__str__()))
|
|
284
|
+
|
|
285
|
+ # Filelock
|
209
|
286
|
except filelock.Timeout as e:
|
210
|
287
|
log = 'Mail Fetcher Lock Timeout {}'
|
211
|
288
|
logger.warning(self, log.format(e.__str__()))
|
|
289
|
+
|
|
290
|
+ # IMAP
|
|
291
|
+ # TODO - G.M - 10-01-2017 - Support imapclient exceptions
|
|
292
|
+ # when Imapclient stable will be 2.0+
|
|
293
|
+
|
|
294
|
+ # Others
|
212
|
295
|
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__()))
|
|
296
|
+ log = 'Mail Fetcher error {}'
|
|
297
|
+ logger.error(self, log.format(e.__str__()))
|
|
298
|
+
|
|
299
|
+ finally:
|
|
300
|
+ # INFO - G.M - 2018-01-09 - Connection closing
|
|
301
|
+ # Properly close connection according to
|
|
302
|
+ # https://github.com/mjs/imapclient/pull/279/commits/043e4bd0c5c775c5a08cb5f1baa93876a46732ee
|
|
303
|
+ # TODO : Use __exit__ method instead when imapclient stable will
|
|
304
|
+ # be 2.0+ .
|
|
305
|
+ if imapc:
|
|
306
|
+ logger.debug(self, 'Try logout')
|
|
307
|
+ try:
|
|
308
|
+ imapc.logout()
|
|
309
|
+ except Exception:
|
|
310
|
+ try:
|
|
311
|
+ imapc.shutdown()
|
|
312
|
+ except Exception as e:
|
|
313
|
+ log = "Can't logout, connection broken ? {}"
|
|
314
|
+ logger.error(self, log.format(e.__str__()))
|
|
315
|
+
|
|
316
|
+ if sleep_after_connection:
|
|
317
|
+ logger.debug(self, 'sleep for {}'.format(self.heartbeat))
|
|
318
|
+ time.sleep(self.heartbeat)
|
|
319
|
+
|
|
320
|
+ log = 'Mail Fetcher stopped'
|
|
321
|
+ logger.debug(self, log)
|
|
322
|
+
|
|
323
|
+ def _check_mail(self, imapc: imapclient.IMAPClient) -> None:
|
|
324
|
+ with self.lock.acquire(
|
|
325
|
+ timeout=MAIL_FETCHER_FILELOCK_TIMEOUT
|
|
326
|
+ ):
|
|
327
|
+ messages = self._fetch(imapc)
|
|
328
|
+ cleaned_mails = [DecodedMail(m.message, m.uid)
|
|
329
|
+ for m in messages]
|
|
330
|
+ self._notify_tracim(cleaned_mails, imapc)
|
216
|
331
|
|
217
|
332
|
def stop(self) -> None:
|
218
|
333
|
self._is_active = False
|
219
|
334
|
|
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]:
|
|
335
|
+ def _fetch(
|
|
336
|
+ self,
|
|
337
|
+ imapc: imapclient.IMAPClient,
|
|
338
|
+ ) -> typing.List[MessageContainer]:
|
258
|
339
|
"""
|
259
|
340
|
Get news message from mailbox
|
260
|
341
|
:return: list of new mails
|
261
|
342
|
"""
|
262
|
343
|
messages = []
|
263
|
|
- # select mailbox
|
264
|
|
- logger.debug(self, 'Fetch messages from folder {}'.format(
|
265
|
|
- self.folder,
|
|
344
|
+
|
|
345
|
+ logger.debug(self, 'Fetch unseen messages')
|
|
346
|
+ uids = imapc.search(['UNSEEN'])
|
|
347
|
+ logger.debug(self, 'Found {} unseen mails'.format(
|
|
348
|
+ len(uids),
|
266
|
349
|
))
|
267
|
|
- rv, data = self._connection.select(self.folder)
|
268
|
|
- logger.debug(self, 'Response status {}'.format(
|
269
|
|
- rv,
|
|
350
|
+ imapc.add_flags(uids, IMAP_SEEN_FLAG)
|
|
351
|
+ logger.debug(self, 'Temporary Flag {} mails as seen'.format(
|
|
352
|
+ len(uids),
|
270
|
353
|
))
|
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,
|
|
354
|
+ for msgid, data in imapc.fetch(uids, ['BODY.PEEK[]']).items():
|
|
355
|
+ # INFO - G.M - 2017-12-08 - Fetch BODY.PEEK[]
|
|
356
|
+ # Retrieve all mail(body and header) but don't set mail
|
|
357
|
+ # as seen because of PEEK
|
|
358
|
+ # see rfc3501
|
|
359
|
+ logger.debug(self, 'Fetch mail "{}"'.format(
|
|
360
|
+ msgid,
|
281
|
361
|
))
|
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)))
|
|
362
|
+ msg = message_from_bytes(data[b'BODY[]'])
|
|
363
|
+ msg_container = MessageContainer(msg, msgid)
|
|
364
|
+ messages.append(msg_container)
|
313
|
365
|
return messages
|
314
|
366
|
|
315
|
367
|
def _notify_tracim(
|
316
|
368
|
self,
|
317
|
369
|
mails: typing.List[DecodedMail],
|
|
370
|
+ imapc: imapclient.IMAPClient
|
318
|
371
|
) -> None:
|
319
|
372
|
"""
|
320
|
373
|
Send http request to tracim endpoint
|
321
|
374
|
:param mails: list of mails to send
|
322
|
|
- :return: unsended mails
|
|
375
|
+ :return: none
|
323
|
376
|
"""
|
324
|
377
|
logger.debug(self, 'Notify tracim about {} new responses'.format(
|
325
|
378
|
len(mails),
|
326
|
379
|
))
|
327
|
|
- unsended_mails = []
|
328
|
380
|
# TODO BS 20171124: Look around mail.get_from_address(), mail.get_key()
|
329
|
381
|
# , mail.get_body() etc ... for raise InvalidEmailError if missing
|
330
|
382
|
# required informations (actually get_from_address raise IndexError
|
331
|
383
|
# if no from address for example) and catch it here
|
332
|
384
|
while mails:
|
333
|
385
|
mail = mails.pop()
|
|
386
|
+ body = mail.get_body(
|
|
387
|
+ use_html_parsing=self.use_html_parsing,
|
|
388
|
+ use_txt_parsing=self.use_txt_parsing,
|
|
389
|
+ )
|
|
390
|
+ from_address = mail.get_from_address()
|
|
391
|
+
|
|
392
|
+ # don't create element for 'empty' mail
|
|
393
|
+ if not body:
|
|
394
|
+ logger.warning(
|
|
395
|
+ self,
|
|
396
|
+ 'Mail from {} has not valable content'.format(
|
|
397
|
+ from_address
|
|
398
|
+ ),
|
|
399
|
+ )
|
|
400
|
+ continue
|
|
401
|
+
|
334
|
402
|
msg = {'token': self.token,
|
335
|
|
- 'user_mail': mail.get_from_address(),
|
|
403
|
+ 'user_mail': from_address,
|
336
|
404
|
'content_id': mail.get_key(),
|
337
|
405
|
'payload': {
|
338
|
|
- 'content': mail.get_body(
|
339
|
|
- use_html_parsing=self.use_html_parsing,
|
340
|
|
- use_txt_parsing=self.use_txt_parsing),
|
|
406
|
+ 'content': body,
|
341
|
407
|
}}
|
342
|
408
|
try:
|
343
|
409
|
logger.debug(
|
|
@@ -357,64 +423,15 @@ class MailFetcher(object):
|
357
|
423
|
))
|
358
|
424
|
# Flag all correctly checked mail, unseen the others
|
359
|
425
|
if r.status_code in [200, 204, 400]:
|
360
|
|
- self._set_flag(mail.uid, IMAP_CHECKED_FLAG)
|
|
426
|
+ imapc.add_flags((mail.uid,), IMAP_CHECKED_FLAG)
|
361
|
427
|
else:
|
362
|
|
- self._unset_flag(mail.uid, IMAP_SEEN_FLAG)
|
|
428
|
+ imapc.remove_flags((mail.uid,), IMAP_SEEN_FLAG)
|
363
|
429
|
# TODO - G.M - Verify exception correctly works
|
364
|
430
|
except requests.exceptions.Timeout as e:
|
365
|
431
|
log = 'Timeout error to transmit fetched mail to tracim : {}'
|
366
|
432
|
logger.error(self, log.format(str(e)))
|
367
|
|
- unsended_mails.append(mail)
|
368
|
|
- self._unset_flag(mail.uid, IMAP_SEEN_FLAG)
|
|
433
|
+ imapc.remove_flags((mail.uid,), IMAP_SEEN_FLAG)
|
369
|
434
|
except requests.exceptions.RequestException as e:
|
370
|
435
|
log = 'Fail to transmit fetched mail to tracim : {}'
|
371
|
436
|
logger.error(self, log.format(str(e)))
|
372
|
|
- self._unset_flag(mail.uid, IMAP_SEEN_FLAG)
|
373
|
|
-
|
374
|
|
- def _set_flag(
|
375
|
|
- self,
|
376
|
|
- uid: int,
|
377
|
|
- flag: str,
|
378
|
|
- ) -> None:
|
379
|
|
- assert uid is not None
|
380
|
|
-
|
381
|
|
- rv, data = self._connection.store(
|
382
|
|
- uid,
|
383
|
|
- '+FLAGS',
|
384
|
|
- flag,
|
385
|
|
- )
|
386
|
|
- if rv == 'OK':
|
387
|
|
- log = 'Message {uid} set as {flag}.'.format(
|
388
|
|
- uid=uid,
|
389
|
|
- flag=flag)
|
390
|
|
- logger.debug(self, log)
|
391
|
|
- else:
|
392
|
|
- log = 'Can not set Message {uid} as {flag} : {rv}'.format(
|
393
|
|
- uid=uid,
|
394
|
|
- flag=flag,
|
395
|
|
- rv=rv)
|
396
|
|
- logger.error(self, log)
|
397
|
|
-
|
398
|
|
- def _unset_flag(
|
399
|
|
- self,
|
400
|
|
- uid: int,
|
401
|
|
- flag: str,
|
402
|
|
- ) -> None:
|
403
|
|
- assert uid is not None
|
404
|
|
-
|
405
|
|
- rv, data = self._connection.store(
|
406
|
|
- uid,
|
407
|
|
- '-FLAGS',
|
408
|
|
- flag,
|
409
|
|
- )
|
410
|
|
- if rv == 'OK':
|
411
|
|
- log = 'Message {uid} unset as {flag}.'.format(
|
412
|
|
- uid=uid,
|
413
|
|
- flag=flag)
|
414
|
|
- logger.debug(self, log)
|
415
|
|
- else:
|
416
|
|
- log = 'Can not unset Message {uid} as {flag} : {rv}'.format(
|
417
|
|
- uid=uid,
|
418
|
|
- flag=flag,
|
419
|
|
- rv=rv)
|
420
|
|
- logger.error(self, log)
|
|
437
|
+ imapc.remove_flags((mail.uid,), IMAP_SEEN_FLAG)
|