Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 
 
 

573 wiersze
17 KiB

  1. # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  2. # MIT License. See license.txt
  3. from __future__ import unicode_literals
  4. from six.moves import range
  5. import time, _socket, poplib, imaplib, email, email.utils, datetime, chardet, re, hashlib
  6. from email_reply_parser import EmailReplyParser
  7. from email.header import decode_header
  8. import frappe
  9. from frappe import _
  10. from frappe.utils import (extract_email_id, convert_utc_to_user_timezone, now,
  11. cint, cstr, strip, markdown)
  12. from frappe.utils.scheduler import log
  13. from frappe.utils.file_manager import get_random_filename, save_file, MaxFileSizeReachedError
  14. import re
  15. class EmailSizeExceededError(frappe.ValidationError): pass
  16. class EmailTimeoutError(frappe.ValidationError): pass
  17. class TotalSizeExceededError(frappe.ValidationError): pass
  18. class LoginLimitExceeded(frappe.ValidationError): pass
  19. class EmailServer:
  20. """Wrapper for POP server to pull emails."""
  21. def __init__(self, args=None):
  22. self.setup(args)
  23. def setup(self, args=None):
  24. # overrride
  25. self.settings = args or frappe._dict()
  26. def check_mails(self):
  27. # overrride
  28. return True
  29. def process_message(self, mail):
  30. # overrride
  31. pass
  32. def connect(self):
  33. """Connect to **Email Account**."""
  34. if cint(self.settings.use_imap):
  35. return self.connect_imap()
  36. else:
  37. return self.connect_pop()
  38. def connect_imap(self):
  39. """Connect to IMAP"""
  40. try:
  41. if cint(self.settings.use_ssl):
  42. self.imap = Timed_IMAP4_SSL(self.settings.host, timeout=frappe.conf.get("pop_timeout"))
  43. else:
  44. self.imap = Timed_IMAP4(self.settings.host, timeout=frappe.conf.get("pop_timeout"))
  45. self.imap.login(self.settings.username, self.settings.password)
  46. # connection established!
  47. return True
  48. except _socket.error:
  49. # Invalid mail server -- due to refusing connection
  50. frappe.msgprint(_('Invalid Mail Server. Please rectify and try again.'))
  51. raise
  52. except Exception as e:
  53. frappe.msgprint(_('Cannot connect: {0}').format(str(e)))
  54. raise
  55. def connect_pop(self):
  56. #this method return pop connection
  57. try:
  58. if cint(self.settings.use_ssl):
  59. self.pop = Timed_POP3_SSL(self.settings.host, timeout=frappe.conf.get("pop_timeout"))
  60. else:
  61. self.pop = Timed_POP3(self.settings.host, timeout=frappe.conf.get("pop_timeout"))
  62. self.pop.user(self.settings.username)
  63. self.pop.pass_(self.settings.password)
  64. # connection established!
  65. return True
  66. except _socket.error:
  67. # log performs rollback and logs error in Error Log
  68. log("receive.connect_pop")
  69. # Invalid mail server -- due to refusing connection
  70. frappe.msgprint(_('Invalid Mail Server. Please rectify and try again.'))
  71. raise
  72. except poplib.error_proto as e:
  73. if self.is_temporary_system_problem(e):
  74. return False
  75. else:
  76. frappe.msgprint(_('Invalid User Name or Support Password. Please rectify and try again.'))
  77. raise
  78. def get_messages(self):
  79. """Returns new email messages in a list."""
  80. if not self.check_mails():
  81. return # nothing to do
  82. frappe.db.commit()
  83. if not self.connect():
  84. return
  85. uid_list = []
  86. try:
  87. # track if errors arised
  88. self.errors = False
  89. self.latest_messages = []
  90. self.seen_status = {}
  91. self.uid_reindexed = False
  92. uid_list = email_list = self.get_new_mails()
  93. if not email_list:
  94. return
  95. num = num_copy = len(email_list)
  96. # WARNING: Hard coded max no. of messages to be popped
  97. if num > 50: num = 50
  98. # size limits
  99. self.total_size = 0
  100. self.max_email_size = cint(frappe.local.conf.get("max_email_size"))
  101. self.max_total_size = 5 * self.max_email_size
  102. for i, message_meta in enumerate(email_list):
  103. # do not pull more than NUM emails
  104. if (i+1) > num:
  105. break
  106. try:
  107. self.retrieve_message(message_meta, i+1)
  108. except (TotalSizeExceededError, EmailTimeoutError, LoginLimitExceeded):
  109. break
  110. # WARNING: Mark as read - message number 101 onwards from the pop list
  111. # This is to avoid having too many messages entering the system
  112. num = num_copy
  113. if not cint(self.settings.use_imap):
  114. if num > 100 and not self.errors:
  115. for m in range(101, num+1):
  116. self.pop.dele(m)
  117. except Exception as e:
  118. if self.has_login_limit_exceeded(e):
  119. pass
  120. else:
  121. raise
  122. finally:
  123. # no matter the exception, pop should quit if connected
  124. if cint(self.settings.use_imap):
  125. self.imap.logout()
  126. else:
  127. self.pop.quit()
  128. out = { "latest_messages": self.latest_messages }
  129. if self.settings.use_imap:
  130. out.update({
  131. "uid_list": uid_list,
  132. "seen_status": self.seen_status,
  133. "uid_reindexed": self.uid_reindexed
  134. })
  135. return out
  136. def get_new_mails(self):
  137. """Return list of new mails"""
  138. if cint(self.settings.use_imap):
  139. email_list = []
  140. self.check_imap_uidvalidity()
  141. self.imap.select("Inbox", readonly=False)
  142. response, message = self.imap.uid('search', None, self.settings.email_sync_rule)
  143. if message[0]:
  144. email_list = message[0].split()
  145. else:
  146. email_list = self.pop.list()[1]
  147. return email_list
  148. def check_imap_uidvalidity(self):
  149. # compare the UIDVALIDITY of email account and imap server
  150. uid_validity = self.settings.uid_validity
  151. responce, message = self.imap.status("Inbox", "(UIDVALIDITY UIDNEXT)")
  152. current_uid_validity = self.parse_imap_responce("UIDVALIDITY", message[0])
  153. if not current_uid_validity:
  154. frappe.throw(_("Can not find UIDVALIDITY in imap status response"))
  155. uidnext = int(self.parse_imap_responce("UIDNEXT", message[0]) or "1")
  156. frappe.db.set_value("Email Account", self.settings.email_account, "uidnext", uidnext)
  157. if not uid_validity or uid_validity != current_uid_validity:
  158. # uidvalidity changed & all email uids are reindexed by server
  159. frappe.db.sql("""update `tabCommunication` set uid=-1 where communication_medium='Email'
  160. and email_account='{email_account}'""".format(email_account=self.settings.email_account))
  161. frappe.db.sql("""update `tabEmail Account` set uidvalidity='{uidvalidity}', uidnext={uidnext} where
  162. name='{email_account}'""".format(
  163. uidvalidity=current_uid_validity,
  164. uidnext=uidnext,
  165. email_account=self.settings.email_account)
  166. )
  167. # uid validity not found pulling emails for first time
  168. if not uid_validity:
  169. self.settings.email_sync_rule = "UNSEEN"
  170. return
  171. sync_count = 100 if uid_validity else int(self.settings.initial_sync_count)
  172. from_uid = 1 if uidnext < (sync_count + 1) or (uidnext - sync_count) < 1 else uidnext - sync_count
  173. # sync last 100 email
  174. self.settings.email_sync_rule = "UID {}:{}".format(from_uid, uidnext)
  175. self.uid_reindexed = True
  176. elif uid_validity == current_uid_validity:
  177. return
  178. def parse_imap_responce(self, cmd, responce):
  179. pattern = r"(?<={cmd} )[0-9]*".format(cmd=cmd)
  180. match = re.search(pattern, responce, re.U | re.I)
  181. if match:
  182. return match.group(0)
  183. else:
  184. return None
  185. def retrieve_message(self, message_meta, msg_num=None):
  186. incoming_mail = None
  187. try:
  188. self.validate_message_limits(message_meta)
  189. if cint(self.settings.use_imap):
  190. status, message = self.imap.uid('fetch', message_meta, '(BODY.PEEK[] BODY.PEEK[HEADER] FLAGS)')
  191. raw, header, ignore = message
  192. self.get_email_seen_status(message_meta, raw[0])
  193. self.latest_messages.append(raw[1])
  194. else:
  195. msg = self.pop.retr(msg_num)
  196. self.latest_messages.append(b'\n'.join(msg[1]))
  197. except (TotalSizeExceededError, EmailTimeoutError):
  198. # propagate this error to break the loop
  199. self.errors = True
  200. raise
  201. except Exception as e:
  202. if self.has_login_limit_exceeded(e):
  203. self.errors = True
  204. raise LoginLimitExceeded(e)
  205. else:
  206. # log performs rollback and logs error in Error Log
  207. log("receive.get_messages", self.make_error_msg(msg_num, incoming_mail))
  208. self.errors = True
  209. frappe.db.rollback()
  210. if not cint(self.settings.use_imap):
  211. self.pop.dele(msg_num)
  212. else:
  213. # mark as seen
  214. self.imap.uid('STORE', message_meta, '+FLAGS', '(\\SEEN)')
  215. else:
  216. if not cint(self.settings.use_imap):
  217. self.pop.dele(msg_num)
  218. else:
  219. # mark as seen
  220. self.imap.uid('STORE', message_meta, '+FLAGS', '(\\SEEN)')
  221. def get_email_seen_status(self, uid, flag_string):
  222. """ parse the email FLAGS response """
  223. if not flag_string:
  224. return None
  225. flags = []
  226. for flag in imaplib.ParseFlags(flag_string) or []:
  227. pattern = re.compile("\w+")
  228. match = re.search(pattern, flag)
  229. flags.append(match.group(0))
  230. if "Seen" in flags:
  231. self.seen_status.update({ uid: "SEEN" })
  232. else:
  233. self.seen_status.update({ uid: "UNSEEN" })
  234. def has_login_limit_exceeded(self, e):
  235. return "-ERR Exceeded the login limit" in strip(cstr(e.message))
  236. def is_temporary_system_problem(self, e):
  237. messages = (
  238. "-ERR [SYS/TEMP] Temporary system problem. Please try again later.",
  239. "Connection timed out",
  240. )
  241. for message in messages:
  242. if message in strip(cstr(e.message)) or message in strip(cstr(getattr(e, 'strerror', ''))):
  243. return True
  244. return False
  245. def validate_message_limits(self, message_meta):
  246. # throttle based on email size
  247. if not self.max_email_size:
  248. return
  249. m, size = message_meta.split()
  250. size = cint(size)
  251. if size < self.max_email_size:
  252. self.total_size += size
  253. if self.total_size > self.max_total_size:
  254. raise TotalSizeExceededError
  255. else:
  256. raise EmailSizeExceededError
  257. def make_error_msg(self, msg_num, incoming_mail):
  258. error_msg = "Error in retrieving email."
  259. if not incoming_mail:
  260. try:
  261. # retrieve headers
  262. incoming_mail = Email(b'\n'.join(self.pop.top(msg_num, 5)[1]))
  263. except:
  264. pass
  265. if incoming_mail:
  266. error_msg += "\nDate: {date}\nFrom: {from_email}\nSubject: {subject}\n".format(
  267. date=incoming_mail.date, from_email=incoming_mail.from_email, subject=incoming_mail.subject)
  268. return error_msg
  269. def update_flag(self, uid_list={}):
  270. """ set all uids mails the flag as seen """
  271. if not uid_list:
  272. return
  273. if not self.connect():
  274. return
  275. self.imap.select("Inbox")
  276. for uid, operation in uid_list.iteritems():
  277. if not uid: continue
  278. op = "+FLAGS" if operation == "Read" else "-FLAGS"
  279. try:
  280. self.imap.uid('STORE', uid, op, '(\\SEEN)')
  281. except Exception as e:
  282. continue
  283. class Email:
  284. """Wrapper for an email."""
  285. def __init__(self, content):
  286. """Parses headers, content, attachments from given raw message.
  287. :param content: Raw message."""
  288. self.raw = content
  289. self.mail = email.message_from_string(self.raw)
  290. self.text_content = ''
  291. self.html_content = ''
  292. self.attachments = []
  293. self.cid_map = {}
  294. self.parse()
  295. self.set_content_and_type()
  296. self.set_subject()
  297. self.set_from()
  298. self.message_id = (self.mail.get('Message-ID') or "").strip(" <>")
  299. if self.mail["Date"]:
  300. try:
  301. utc = email.utils.mktime_tz(email.utils.parsedate_tz(self.mail["Date"]))
  302. utc_dt = datetime.datetime.utcfromtimestamp(utc)
  303. self.date = convert_utc_to_user_timezone(utc_dt).strftime('%Y-%m-%d %H:%M:%S')
  304. except:
  305. self.date = now()
  306. else:
  307. self.date = now()
  308. if self.date > now():
  309. self.date = now()
  310. def parse(self):
  311. """Walk and process multi-part email."""
  312. for part in self.mail.walk():
  313. self.process_part(part)
  314. def set_subject(self):
  315. """Parse and decode `Subject` header."""
  316. _subject = decode_header(self.mail.get("Subject", "No Subject"))
  317. self.subject = _subject[0][0] or ""
  318. if _subject[0][1]:
  319. self.subject = self.subject.decode(_subject[0][1])
  320. else:
  321. # assume that the encoding is utf-8
  322. self.subject = self.subject.decode("utf-8")[:140]
  323. if not self.subject:
  324. self.subject = "No Subject"
  325. def set_from(self):
  326. # gmail mailing-list compatibility
  327. # use X-Original-Sender if available, as gmail sometimes modifies the 'From'
  328. _from_email = self.decode_email(self.mail.get("X-Original-From") or self.mail["From"])
  329. _reply_to = self.decode_email(self.mail.get("Reply-To"))
  330. if _reply_to and not frappe.db.get_value('Email Account', {"email_id":_reply_to}, 'email_id'):
  331. self.from_email = extract_email_id(_reply_to)
  332. else:
  333. self.from_email = extract_email_id(_from_email)
  334. if self.from_email:
  335. self.from_email = self.from_email.lower()
  336. self.from_real_name = email.utils.parseaddr(_from_email)[0] if "@" in _from_email else _from_email
  337. def decode_email(self, email):
  338. if not email: return
  339. decoded = ""
  340. for part, encoding in decode_header(frappe.as_unicode(email).replace("\""," ").replace("\'"," ")):
  341. if encoding:
  342. decoded += part.decode(encoding)
  343. else:
  344. decoded += part.decode('utf-8')
  345. return decoded
  346. def set_content_and_type(self):
  347. self.content, self.content_type = '[Blank Email]', 'text/plain'
  348. if self.html_content:
  349. self.content, self.content_type = self.html_content, 'text/html'
  350. else:
  351. self.content, self.content_type = EmailReplyParser.read(self.text_content).text.replace("\n","\n\n"), 'text/plain'
  352. def process_part(self, part):
  353. """Parse email `part` and set it to `text_content`, `html_content` or `attachments`."""
  354. content_type = part.get_content_type()
  355. if content_type == 'text/plain':
  356. self.text_content += self.get_payload(part)
  357. elif content_type == 'text/html':
  358. self.html_content += self.get_payload(part)
  359. elif content_type == 'message/rfc822':
  360. # sent by outlook when another email is sent as an attachment to this email
  361. self.show_attached_email_headers_in_content(part)
  362. elif part.get_filename() or 'image' in content_type:
  363. self.get_attachment(part)
  364. def show_attached_email_headers_in_content(self, part):
  365. # get the multipart/alternative message
  366. message = list(part.walk())[1]
  367. headers = []
  368. for key in ('From', 'To', 'Subject', 'Date'):
  369. value = cstr(message.get(key))
  370. if value:
  371. headers.append('{label}: {value}'.format(label=_(key), value=value))
  372. self.text_content += '\n'.join(headers)
  373. self.html_content += '<hr>' + '\n'.join('<p>{0}</p>'.format(h) for h in headers)
  374. if not message.is_multipart() and message.get_content_type()=='text/plain':
  375. # email.parser didn't parse it!
  376. text_content = self.get_payload(message)
  377. self.text_content += text_content
  378. self.html_content += markdown(text_content)
  379. def get_charset(self, part):
  380. """Detect chartset."""
  381. charset = part.get_content_charset()
  382. if not charset:
  383. charset = chardet.detect(str(part))['encoding']
  384. return charset
  385. def get_payload(self, part):
  386. charset = self.get_charset(part)
  387. try:
  388. return unicode(part.get_payload(decode=True), str(charset), "ignore")
  389. except LookupError:
  390. return part.get_payload()
  391. def get_attachment(self, part):
  392. #charset = self.get_charset(part)
  393. fcontent = part.get_payload(decode=True)
  394. if fcontent:
  395. content_type = part.get_content_type()
  396. fname = part.get_filename()
  397. if fname:
  398. try:
  399. fname = fname.replace('\n', ' ').replace('\r', '')
  400. fname = cstr(decode_header(fname)[0][0])
  401. except:
  402. fname = get_random_filename(content_type=content_type)
  403. else:
  404. fname = get_random_filename(content_type=content_type)
  405. self.attachments.append({
  406. 'content_type': content_type,
  407. 'fname': fname,
  408. 'fcontent': fcontent,
  409. })
  410. cid = (part.get("Content-Id") or "").strip("><")
  411. if cid:
  412. self.cid_map[fname] = cid
  413. def save_attachments_in_doc(self, doc):
  414. """Save email attachments in given document."""
  415. saved_attachments = []
  416. for attachment in self.attachments:
  417. try:
  418. file_data = save_file(attachment['fname'], attachment['fcontent'],
  419. doc.doctype, doc.name, is_private=1)
  420. saved_attachments.append(file_data)
  421. if attachment['fname'] in self.cid_map:
  422. self.cid_map[file_data.name] = self.cid_map[attachment['fname']]
  423. except MaxFileSizeReachedError:
  424. # WARNING: bypass max file size exception
  425. pass
  426. except frappe.DuplicateEntryError:
  427. # same file attached twice??
  428. pass
  429. return saved_attachments
  430. def get_thread_id(self):
  431. """Extract thread ID from `[]`"""
  432. l = re.findall('(?<=\[)[\w/-]+', self.subject)
  433. return l and l[0] or None
  434. # fix due to a python bug in poplib that limits it to 2048
  435. poplib._MAXLINE = 20480
  436. class TimerMixin(object):
  437. def __init__(self, *args, **kwargs):
  438. self.timeout = kwargs.pop('timeout', 0.0)
  439. self.elapsed_time = 0.0
  440. self._super.__init__(self, *args, **kwargs)
  441. if self.timeout:
  442. # set per operation timeout to one-fifth of total pop timeout
  443. self.sock.settimeout(self.timeout / 5.0)
  444. def _getline(self, *args, **kwargs):
  445. start_time = time.time()
  446. ret = self._super._getline(self, *args, **kwargs)
  447. self.elapsed_time += time.time() - start_time
  448. if self.timeout and self.elapsed_time > self.timeout:
  449. raise EmailTimeoutError
  450. return ret
  451. def quit(self, *args, **kwargs):
  452. self.elapsed_time = 0.0
  453. return self._super.quit(self, *args, **kwargs)
  454. class Timed_POP3(TimerMixin, poplib.POP3):
  455. _super = poplib.POP3
  456. class Timed_POP3_SSL(TimerMixin, poplib.POP3_SSL):
  457. _super = poplib.POP3_SSL
  458. class Timed_IMAP4(TimerMixin, imaplib.IMAP4):
  459. _super = imaplib.IMAP4
  460. class Timed_IMAP4_SSL(TimerMixin, imaplib.IMAP4_SSL):
  461. _super = imaplib.IMAP4_SSL