No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 
 
 

935 líneas
29 KiB

  1. # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  2. # License: MIT. See LICENSE
  3. import datetime
  4. import email
  5. import email.utils
  6. import imaplib
  7. import poplib
  8. import re
  9. import time
  10. import json
  11. from email.header import decode_header
  12. import _socket
  13. import chardet
  14. from email_reply_parser import EmailReplyParser
  15. import frappe
  16. from frappe import _, safe_decode, safe_encode
  17. from frappe.core.doctype.file.file import (MaxFileSizeReachedError,
  18. get_random_filename)
  19. from frappe.utils import (cint, convert_utc_to_user_timezone, cstr,
  20. extract_email_id, markdown, now, parse_addr, strip, get_datetime,
  21. add_days, sanitize_html)
  22. from frappe.utils.user import is_system_user
  23. from frappe.utils.html_utils import clean_email_html
  24. # fix due to a python bug in poplib that limits it to 2048
  25. poplib._MAXLINE = 20480
  26. class EmailSizeExceededError(frappe.ValidationError): pass
  27. class EmailTimeoutError(frappe.ValidationError): pass
  28. class TotalSizeExceededError(frappe.ValidationError): pass
  29. class LoginLimitExceeded(frappe.ValidationError): pass
  30. class SentEmailInInboxError(Exception):
  31. pass
  32. class EmailServer:
  33. """Wrapper for POP server to pull emails."""
  34. def __init__(self, args=None):
  35. self.setup(args)
  36. def setup(self, args=None):
  37. # overrride
  38. self.settings = args or frappe._dict()
  39. def check_mails(self):
  40. # overrride
  41. return True
  42. def process_message(self, mail):
  43. # overrride
  44. pass
  45. def connect(self):
  46. """Connect to **Email Account**."""
  47. if cint(self.settings.use_imap):
  48. return self.connect_imap()
  49. else:
  50. return self.connect_pop()
  51. def connect_imap(self):
  52. """Connect to IMAP"""
  53. try:
  54. if cint(self.settings.use_ssl):
  55. self.imap = Timed_IMAP4_SSL(self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout"))
  56. else:
  57. self.imap = Timed_IMAP4(self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout"))
  58. self.imap.login(self.settings.username, self.settings.password)
  59. # connection established!
  60. return True
  61. except _socket.error:
  62. # Invalid mail server -- due to refusing connection
  63. frappe.msgprint(_('Invalid Mail Server. Please rectify and try again.'))
  64. raise
  65. def connect_pop(self):
  66. #this method return pop connection
  67. try:
  68. if cint(self.settings.use_ssl):
  69. self.pop = Timed_POP3_SSL(self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout"))
  70. else:
  71. self.pop = Timed_POP3(self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout"))
  72. self.pop.user(self.settings.username)
  73. self.pop.pass_(self.settings.password)
  74. # connection established!
  75. return True
  76. except _socket.error:
  77. # log performs rollback and logs error in Error Log
  78. frappe.log_error("receive.connect_pop")
  79. # Invalid mail server -- due to refusing connection
  80. frappe.msgprint(_('Invalid Mail Server. Please rectify and try again.'))
  81. raise
  82. except poplib.error_proto as e:
  83. if self.is_temporary_system_problem(e):
  84. return False
  85. else:
  86. frappe.msgprint(_('Invalid User Name or Support Password. Please rectify and try again.'))
  87. raise
  88. def select_imap_folder(self, folder):
  89. self.imap.select(folder)
  90. def logout(self):
  91. if cint(self.settings.use_imap):
  92. self.imap.logout()
  93. else:
  94. self.pop.quit()
  95. return
  96. def get_messages(self, folder="INBOX"):
  97. """Returns new email messages in a list."""
  98. if not (self.check_mails() or self.connect()):
  99. return []
  100. frappe.db.commit()
  101. uid_list = []
  102. try:
  103. # track if errors arised
  104. self.errors = False
  105. self.latest_messages = []
  106. self.seen_status = {}
  107. self.uid_reindexed = False
  108. uid_list = email_list = self.get_new_mails(folder)
  109. if not email_list:
  110. return
  111. num = num_copy = len(email_list)
  112. # WARNING: Hard coded max no. of messages to be popped
  113. if num > 50: num = 50
  114. # size limits
  115. self.total_size = 0
  116. self.max_email_size = cint(frappe.local.conf.get("max_email_size"))
  117. self.max_total_size = 5 * self.max_email_size
  118. for i, message_meta in enumerate(email_list[:num]):
  119. try:
  120. self.retrieve_message(message_meta, i+1)
  121. except (TotalSizeExceededError, EmailTimeoutError, LoginLimitExceeded):
  122. break
  123. # WARNING: Mark as read - message number 101 onwards from the pop list
  124. # This is to avoid having too many messages entering the system
  125. num = num_copy
  126. if not cint(self.settings.use_imap):
  127. if num > 100 and not self.errors:
  128. for m in range(101, num+1):
  129. self.pop.dele(m)
  130. except Exception as e:
  131. if self.has_login_limit_exceeded(e):
  132. pass
  133. else:
  134. raise
  135. out = { "latest_messages": self.latest_messages }
  136. if self.settings.use_imap:
  137. out.update({
  138. "uid_list": uid_list,
  139. "seen_status": self.seen_status,
  140. "uid_reindexed": self.uid_reindexed
  141. })
  142. return out
  143. def get_new_mails(self, folder):
  144. """Return list of new mails"""
  145. if cint(self.settings.use_imap):
  146. email_list = []
  147. self.check_imap_uidvalidity(folder)
  148. readonly = False if self.settings.email_sync_rule == "UNSEEN" else True
  149. self.imap.select(folder, readonly=readonly)
  150. response, message = self.imap.uid('search', None, self.settings.email_sync_rule)
  151. if message[0]:
  152. email_list = message[0].split()
  153. else:
  154. email_list = self.pop.list()[1]
  155. return email_list
  156. def check_imap_uidvalidity(self, folder):
  157. # compare the UIDVALIDITY of email account and imap server
  158. uid_validity = self.settings.uid_validity
  159. response, message = self.imap.status(folder, "(UIDVALIDITY UIDNEXT)")
  160. current_uid_validity = self.parse_imap_response("UIDVALIDITY", message[0]) or 0
  161. uidnext = int(self.parse_imap_response("UIDNEXT", message[0]) or "1")
  162. frappe.db.set_value("Email Account", self.settings.email_account, "uidnext", uidnext)
  163. if not uid_validity or uid_validity != current_uid_validity:
  164. # uidvalidity changed & all email uids are reindexed by server
  165. Communication = frappe.qb.DocType("Communication")
  166. frappe.qb.update(Communication) \
  167. .set(Communication.uid, -1) \
  168. .where(Communication.communication_medium == "Email") \
  169. .where(Communication.email_account == self.settings.email_account).run()
  170. if self.settings.use_imap:
  171. # new update for the IMAP Folder DocType
  172. IMAPFolder = frappe.qb.DocType("IMAP Folder")
  173. frappe.qb.update(IMAPFolder) \
  174. .set(IMAPFolder.uidvalidity, current_uid_validity) \
  175. .set(IMAPFolder.uidnext, uidnext) \
  176. .where(IMAPFolder.parent == self.settings.email_account_name) \
  177. .where(IMAPFolder.folder_name == folder).run()
  178. else:
  179. EmailAccount = frappe.qb.DocType("Email Account")
  180. frappe.qb.update(EmailAccount) \
  181. .set(EmailAccount.uidvalidity, current_uid_validity) \
  182. .set(EmailAccount.uidnext, uidnext) \
  183. .where(EmailAccount.name == self.settings.email_account_name).run()
  184. # uid validity not found pulling emails for first time
  185. if not uid_validity:
  186. self.settings.email_sync_rule = "UNSEEN"
  187. return
  188. sync_count = 100 if uid_validity else int(self.settings.initial_sync_count)
  189. from_uid = 1 if uidnext < (sync_count + 1) or (uidnext - sync_count) < 1 else uidnext - sync_count
  190. # sync last 100 email
  191. self.settings.email_sync_rule = "UID {}:{}".format(from_uid, uidnext)
  192. self.uid_reindexed = True
  193. elif uid_validity == current_uid_validity:
  194. return
  195. def parse_imap_response(self, cmd, response):
  196. pattern = r"(?<={cmd} )[0-9]*".format(cmd=cmd)
  197. match = re.search(pattern, response.decode('utf-8'), re.U | re.I)
  198. if match:
  199. return match.group(0)
  200. else:
  201. return None
  202. def retrieve_message(self, message_meta, msg_num=None):
  203. incoming_mail = None
  204. try:
  205. self.validate_message_limits(message_meta)
  206. if cint(self.settings.use_imap):
  207. status, message = self.imap.uid('fetch', message_meta, '(BODY.PEEK[] BODY.PEEK[HEADER] FLAGS)')
  208. raw = message[0]
  209. self.get_email_seen_status(message_meta, raw[0])
  210. self.latest_messages.append(raw[1])
  211. else:
  212. msg = self.pop.retr(msg_num)
  213. self.latest_messages.append(b'\n'.join(msg[1]))
  214. except (TotalSizeExceededError, EmailTimeoutError):
  215. # propagate this error to break the loop
  216. self.errors = True
  217. raise
  218. except Exception as e:
  219. if self.has_login_limit_exceeded(e):
  220. self.errors = True
  221. raise LoginLimitExceeded(e)
  222. else:
  223. # log performs rollback and logs error in Error Log
  224. frappe.log_error("receive.get_messages", self.make_error_msg(msg_num, incoming_mail))
  225. self.errors = True
  226. frappe.db.rollback()
  227. if not cint(self.settings.use_imap):
  228. self.pop.dele(msg_num)
  229. else:
  230. # mark as seen if email sync rule is UNSEEN (syncing only unseen mails)
  231. if self.settings.email_sync_rule == "UNSEEN":
  232. self.imap.uid('STORE', message_meta, '+FLAGS', '(\\SEEN)')
  233. else:
  234. if not cint(self.settings.use_imap):
  235. self.pop.dele(msg_num)
  236. else:
  237. # mark as seen if email sync rule is UNSEEN (syncing only unseen mails)
  238. if self.settings.email_sync_rule == "UNSEEN":
  239. self.imap.uid('STORE', message_meta, '+FLAGS', '(\\SEEN)')
  240. def get_email_seen_status(self, uid, flag_string):
  241. """ parse the email FLAGS response """
  242. if not flag_string:
  243. return None
  244. flags = []
  245. for flag in imaplib.ParseFlags(flag_string) or []:
  246. pattern = re.compile(r"\w+")
  247. match = re.search(pattern, frappe.as_unicode(flag))
  248. flags.append(match.group(0))
  249. if "Seen" in flags:
  250. self.seen_status.update({ uid: "SEEN" })
  251. else:
  252. self.seen_status.update({ uid: "UNSEEN" })
  253. def has_login_limit_exceeded(self, e):
  254. return "-ERR Exceeded the login limit" in strip(cstr(e.message))
  255. def is_temporary_system_problem(self, e):
  256. messages = (
  257. "-ERR [SYS/TEMP] Temporary system problem. Please try again later.",
  258. "Connection timed out",
  259. )
  260. for message in messages:
  261. if message in strip(cstr(e)) or message in strip(cstr(getattr(e, 'strerror', ''))):
  262. return True
  263. return False
  264. def validate_message_limits(self, message_meta):
  265. # throttle based on email size
  266. if not self.max_email_size:
  267. return
  268. m, size = message_meta.split()
  269. size = cint(size)
  270. if size < self.max_email_size:
  271. self.total_size += size
  272. if self.total_size > self.max_total_size:
  273. raise TotalSizeExceededError
  274. else:
  275. raise EmailSizeExceededError
  276. def make_error_msg(self, msg_num, incoming_mail):
  277. error_msg = "Error in retrieving email."
  278. if not incoming_mail:
  279. try:
  280. # retrieve headers
  281. incoming_mail = Email(b'\n'.join(self.pop.top(msg_num, 5)[1]))
  282. except:
  283. pass
  284. if incoming_mail:
  285. error_msg += "\nDate: {date}\nFrom: {from_email}\nSubject: {subject}\n".format(
  286. date=incoming_mail.date, from_email=incoming_mail.from_email, subject=incoming_mail.subject)
  287. return error_msg
  288. def update_flag(self, folder, uid_list=None):
  289. """ set all uids mails the flag as seen """
  290. if not uid_list:
  291. return
  292. if not self.connect():
  293. return
  294. self.imap.select(folder)
  295. for uid, operation in uid_list.items():
  296. if not uid: continue
  297. op = "+FLAGS" if operation == "Read" else "-FLAGS"
  298. try:
  299. self.imap.uid('STORE', uid, op, '(\\SEEN)')
  300. except Exception:
  301. continue
  302. class Email:
  303. """Wrapper for an email."""
  304. def __init__(self, content):
  305. """Parses headers, content, attachments from given raw message.
  306. :param content: Raw message."""
  307. if isinstance(content, bytes):
  308. self.mail = email.message_from_bytes(content)
  309. else:
  310. self.mail = email.message_from_string(content)
  311. self.raw_message = content
  312. self.text_content = ''
  313. self.html_content = ''
  314. self.attachments = []
  315. self.cid_map = {}
  316. self.parse()
  317. self.set_content_and_type()
  318. self.set_subject()
  319. self.set_from()
  320. self.message_id = (self.mail.get('Message-ID') or "").strip(" <>")
  321. if self.mail["Date"]:
  322. try:
  323. utc = email.utils.mktime_tz(email.utils.parsedate_tz(self.mail["Date"]))
  324. utc_dt = datetime.datetime.utcfromtimestamp(utc)
  325. self.date = convert_utc_to_user_timezone(utc_dt).strftime('%Y-%m-%d %H:%M:%S')
  326. except:
  327. self.date = now()
  328. else:
  329. self.date = now()
  330. if self.date > now():
  331. self.date = now()
  332. @property
  333. def in_reply_to(self):
  334. return (self.mail.get("In-Reply-To") or "").strip(" <>")
  335. def parse(self):
  336. """Walk and process multi-part email."""
  337. for part in self.mail.walk():
  338. self.process_part(part)
  339. def set_subject(self):
  340. """Parse and decode `Subject` header."""
  341. _subject = decode_header(self.mail.get("Subject", "No Subject"))
  342. self.subject = _subject[0][0] or ""
  343. if _subject[0][1]:
  344. self.subject = safe_decode(self.subject, _subject[0][1])
  345. else:
  346. # assume that the encoding is utf-8
  347. self.subject = safe_decode(self.subject)[:140]
  348. if not self.subject:
  349. self.subject = "No Subject"
  350. def set_from(self):
  351. # gmail mailing-list compatibility
  352. # use X-Original-Sender if available, as gmail sometimes modifies the 'From'
  353. _from_email = self.decode_email(self.mail.get("X-Original-From") or self.mail["From"])
  354. _reply_to = self.decode_email(self.mail.get("Reply-To"))
  355. if _reply_to and not frappe.db.get_value('Email Account', {"email_id":_reply_to}, 'email_id'):
  356. self.from_email = extract_email_id(_reply_to)
  357. else:
  358. self.from_email = extract_email_id(_from_email)
  359. if self.from_email:
  360. self.from_email = self.from_email.lower()
  361. self.from_real_name = parse_addr(_from_email)[0] if "@" in _from_email else _from_email
  362. def decode_email(self, email):
  363. if not email: return
  364. decoded = ""
  365. for part, encoding in decode_header(frappe.as_unicode(email).replace("\""," ").replace("\'"," ")):
  366. if encoding:
  367. decoded += part.decode(encoding)
  368. else:
  369. decoded += safe_decode(part)
  370. return decoded
  371. def set_content_and_type(self):
  372. self.content, self.content_type = '[Blank Email]', 'text/plain'
  373. if self.html_content:
  374. self.content, self.content_type = self.html_content, 'text/html'
  375. else:
  376. self.content, self.content_type = EmailReplyParser.read(self.text_content).text.replace("\n","\n\n"), 'text/plain'
  377. def process_part(self, part):
  378. """Parse email `part` and set it to `text_content`, `html_content` or `attachments`."""
  379. content_type = part.get_content_type()
  380. if content_type == 'text/plain':
  381. self.text_content += self.get_payload(part)
  382. elif content_type == 'text/html':
  383. self.html_content += self.get_payload(part)
  384. elif content_type == 'message/rfc822':
  385. # sent by outlook when another email is sent as an attachment to this email
  386. self.show_attached_email_headers_in_content(part)
  387. elif part.get_filename() or 'image' in content_type:
  388. self.get_attachment(part)
  389. def show_attached_email_headers_in_content(self, part):
  390. # get the multipart/alternative message
  391. try:
  392. from html import escape # python 3.x
  393. except ImportError:
  394. from cgi import escape # python 2.x
  395. message = list(part.walk())[1]
  396. headers = []
  397. for key in ('From', 'To', 'Subject', 'Date'):
  398. value = cstr(message.get(key))
  399. if value:
  400. headers.append('{label}: {value}'.format(label=_(key), value=escape(value)))
  401. self.text_content += '\n'.join(headers)
  402. self.html_content += '<hr>' + '\n'.join('<p>{0}</p>'.format(h) for h in headers)
  403. if not message.is_multipart() and message.get_content_type()=='text/plain':
  404. # email.parser didn't parse it!
  405. text_content = self.get_payload(message)
  406. self.text_content += text_content
  407. self.html_content += markdown(text_content)
  408. def get_charset(self, part):
  409. """Detect charset."""
  410. charset = part.get_content_charset()
  411. if not charset:
  412. charset = chardet.detect(safe_encode(cstr(part)))['encoding']
  413. return charset
  414. def get_payload(self, part):
  415. charset = self.get_charset(part)
  416. try:
  417. return str(part.get_payload(decode=True), str(charset), "ignore")
  418. except LookupError:
  419. return part.get_payload()
  420. def get_attachment(self, part):
  421. #charset = self.get_charset(part)
  422. fcontent = part.get_payload(decode=True)
  423. if fcontent:
  424. content_type = part.get_content_type()
  425. fname = part.get_filename()
  426. if fname:
  427. try:
  428. fname = fname.replace('\n', ' ').replace('\r', '')
  429. fname = cstr(decode_header(fname)[0][0])
  430. except:
  431. fname = get_random_filename(content_type=content_type)
  432. else:
  433. fname = get_random_filename(content_type=content_type)
  434. self.attachments.append({
  435. 'content_type': content_type,
  436. 'fname': fname,
  437. 'fcontent': fcontent,
  438. })
  439. cid = (cstr(part.get("Content-Id")) or "").strip("><")
  440. if cid:
  441. self.cid_map[fname] = cid
  442. def save_attachments_in_doc(self, doc):
  443. """Save email attachments in given document."""
  444. saved_attachments = []
  445. for attachment in self.attachments:
  446. try:
  447. _file = frappe.get_doc({
  448. "doctype": "File",
  449. "file_name": attachment['fname'],
  450. "attached_to_doctype": doc.doctype,
  451. "attached_to_name": doc.name,
  452. "is_private": 1,
  453. "content": attachment['fcontent']})
  454. _file.save()
  455. saved_attachments.append(_file)
  456. if attachment['fname'] in self.cid_map:
  457. self.cid_map[_file.name] = self.cid_map[attachment['fname']]
  458. except MaxFileSizeReachedError:
  459. # WARNING: bypass max file size exception
  460. pass
  461. except frappe.FileAlreadyAttachedException:
  462. pass
  463. except frappe.DuplicateEntryError:
  464. # same file attached twice??
  465. pass
  466. return saved_attachments
  467. def get_thread_id(self):
  468. """Extract thread ID from `[]`"""
  469. l = re.findall(r'(?<=\[)[\w/-]+', self.subject)
  470. return l and l[0] or None
  471. def is_reply(self):
  472. return bool(self.in_reply_to)
  473. class InboundMail(Email):
  474. """Class representation of incoming mail along with mail handlers.
  475. """
  476. def __init__(self, content, email_account, uid=None, seen_status=None):
  477. super().__init__(content)
  478. self.email_account = email_account
  479. self.uid = uid or -1
  480. self.seen_status = seen_status or 0
  481. # System documents related to this mail
  482. self._parent_email_queue = None
  483. self._parent_communication = None
  484. self._reference_document = None
  485. self.flags = frappe._dict()
  486. def get_content(self):
  487. if self.content_type == 'text/html':
  488. return clean_email_html(self.content)
  489. def process(self):
  490. """Create communication record from email.
  491. """
  492. if self.is_sender_same_as_receiver() and not self.is_reply():
  493. if frappe.flags.in_test:
  494. print('WARN: Cannot pull email. Sender same as recipient inbox')
  495. raise SentEmailInInboxError
  496. communication = self.is_exist_in_system()
  497. if communication:
  498. communication.update_db(uid=self.uid)
  499. communication.reload()
  500. return communication
  501. self.flags.is_new_communication = True
  502. return self._build_communication_doc()
  503. def _build_communication_doc(self):
  504. data = self.as_dict()
  505. data['doctype'] = "Communication"
  506. if self.parent_communication():
  507. data['in_reply_to'] = self.parent_communication().name
  508. if self.reference_document():
  509. data['reference_doctype'] = self.reference_document().doctype
  510. data['reference_name'] = self.reference_document().name
  511. elif self.email_account.append_to and self.email_account.append_to != 'Communication':
  512. reference_doc = self._create_reference_document(self.email_account.append_to)
  513. if reference_doc:
  514. data['reference_doctype'] = reference_doc.doctype
  515. data['reference_name'] = reference_doc.name
  516. data['is_first'] = True
  517. if self.is_notification():
  518. # Disable notifications for notification.
  519. data['unread_notification_sent'] = 1
  520. if self.seen_status:
  521. data['_seen'] = json.dumps(self.get_users_linked_to_account(self.email_account))
  522. communication = frappe.get_doc(data)
  523. communication.flags.in_receive = True
  524. communication.insert(ignore_permissions=True)
  525. # save attachments
  526. communication._attachments = self.save_attachments_in_doc(communication)
  527. communication.content = sanitize_html(self.replace_inline_images(communication._attachments))
  528. communication.save()
  529. return communication
  530. def replace_inline_images(self, attachments):
  531. # replace inline images
  532. content = self.content
  533. for file in attachments:
  534. if file.name in self.cid_map and self.cid_map[file.name]:
  535. content = content.replace("cid:{0}".format(self.cid_map[file.name]),
  536. file.file_url)
  537. return content
  538. def is_notification(self):
  539. isnotification = self.mail.get("isnotification")
  540. return isnotification and ("notification" in isnotification)
  541. def is_exist_in_system(self):
  542. """Check if this email already exists in the system(as communication document).
  543. """
  544. from frappe.core.doctype.communication.communication import Communication
  545. if not self.message_id:
  546. return
  547. return Communication.find_one_by_filters(message_id = self.message_id,
  548. order_by = 'creation DESC')
  549. def is_sender_same_as_receiver(self):
  550. return self.from_email == self.email_account.email_id
  551. def is_reply_to_system_sent_mail(self):
  552. """Is it a reply to already sent mail.
  553. """
  554. return self.is_reply() and frappe.local.site in self.in_reply_to
  555. def parent_email_queue(self):
  556. """Get parent record from `Email Queue`.
  557. If it is a reply to already sent mail, then there will be a parent record in EMail Queue.
  558. """
  559. from frappe.email.doctype.email_queue.email_queue import EmailQueue
  560. if self._parent_email_queue is not None:
  561. return self._parent_email_queue
  562. parent_email_queue = ''
  563. if self.is_reply_to_system_sent_mail():
  564. parent_email_queue = EmailQueue.find_one_by_filters(message_id=self.in_reply_to)
  565. self._parent_email_queue = parent_email_queue or ''
  566. return self._parent_email_queue
  567. def parent_communication(self):
  568. """Find a related communication so that we can prepare a mail thread.
  569. The way it happens is by using in-reply-to header, and we can't make thread if it does not exist.
  570. Here are the cases to handle:
  571. 1. If mail is a reply to already sent mail, then we can get parent communicaion from
  572. Email Queue record.
  573. 2. Sometimes we send communication name in message-ID directly, use that to get parent communication.
  574. 3. Sender sent a reply but reply is on top of what (s)he sent before,
  575. then parent record exists directly in communication.
  576. """
  577. from frappe.core.doctype.communication.communication import Communication
  578. if self._parent_communication is not None:
  579. return self._parent_communication
  580. if not self.is_reply():
  581. return ''
  582. if not self.is_reply_to_system_sent_mail():
  583. communication = Communication.find_one_by_filters(message_id=self.in_reply_to,
  584. creation = ['>=', self.get_relative_dt(-30)])
  585. elif self.parent_email_queue() and self.parent_email_queue().communication:
  586. communication = Communication.find(self.parent_email_queue().communication, ignore_error=True)
  587. else:
  588. reference = self.in_reply_to
  589. if '@' in self.in_reply_to:
  590. reference, _ = self.in_reply_to.split("@", 1)
  591. communication = Communication.find(reference, ignore_error=True)
  592. self._parent_communication = communication or ''
  593. return self._parent_communication
  594. def reference_document(self):
  595. """Reference document is a document to which mail relate to.
  596. We can get reference document from Parent record(EmailQueue | Communication) if exists.
  597. Otherwise we do subject match to find reference document if we know the reference(append_to) doctype.
  598. """
  599. if self._reference_document is not None:
  600. return self._reference_document
  601. reference_document = ""
  602. parent = self.parent_email_queue() or self.parent_communication()
  603. if parent and parent.reference_doctype:
  604. reference_doctype, reference_name = parent.reference_doctype, parent.reference_name
  605. reference_document = self.get_doc(reference_doctype, reference_name, ignore_error=True)
  606. if not reference_document and self.email_account.append_to:
  607. reference_document = self.match_record_by_subject_and_sender(self.email_account.append_to)
  608. self._reference_document = reference_document or ''
  609. return self._reference_document
  610. def get_reference_name_from_subject(self):
  611. """
  612. Ex: "Re: Your email (#OPP-2020-2334343)"
  613. """
  614. return self.subject.rsplit('#', 1)[-1].strip(' ()')
  615. def match_record_by_subject_and_sender(self, doctype):
  616. """Find a record in the given doctype that matches with email subject and sender.
  617. Cases:
  618. 1. Sometimes record name is part of subject. We can get document by parsing name from subject
  619. 2. Find by matching sender and subject
  620. 3. Find by matching subject alone (Special case)
  621. Ex: when a System User is using Outlook and replies to an email from their own client,
  622. it reaches the Email Account with the threading info lost and the (sender + subject match)
  623. doesn't work because the sender in the first communication was someone different to whom
  624. the system user is replying to via the common email account in Frappe. This fix bypasses
  625. the sender match when the sender is a system user and subject is atleast 10 chars long
  626. (for additional safety)
  627. NOTE: We consider not to match by subject if match record is very old.
  628. """
  629. name = self.get_reference_name_from_subject()
  630. email_fields = self.get_email_fields(doctype)
  631. record = self.get_doc(doctype, name, ignore_error=True) if name else None
  632. if not record:
  633. subject = self.clean_subject(self.subject)
  634. filters = {
  635. email_fields.subject_field: ("like", f"%{subject}%"),
  636. "creation": (">", self.get_relative_dt(days=-60))
  637. }
  638. # Sender check is not needed incase mail is from system user.
  639. if not (len(subject) > 10 and is_system_user(self.from_email)):
  640. filters[email_fields.sender_field] = self.from_email
  641. name = frappe.db.get_value(self.email_account.append_to, filters = filters)
  642. record = self.get_doc(doctype, name, ignore_error=True) if name else None
  643. return record
  644. def _create_reference_document(self, doctype):
  645. """ Create reference document if it does not exist in the system.
  646. """
  647. parent = frappe.new_doc(doctype)
  648. email_fileds = self.get_email_fields(doctype)
  649. if email_fileds.subject_field:
  650. parent.set(email_fileds.subject_field, frappe.as_unicode(self.subject)[:140])
  651. if email_fileds.sender_field:
  652. parent.set(email_fileds.sender_field, frappe.as_unicode(self.from_email))
  653. parent.flags.ignore_mandatory = True
  654. try:
  655. parent.insert(ignore_permissions=True)
  656. except frappe.DuplicateEntryError:
  657. # try and find matching parent
  658. parent_name = frappe.db.get_value(self.email_account.append_to,
  659. {email_fileds.sender_field: self.from_email}
  660. )
  661. if parent_name:
  662. parent.name = parent_name
  663. else:
  664. parent = None
  665. return parent
  666. @staticmethod
  667. def get_doc(doctype, docname, ignore_error=False):
  668. try:
  669. return frappe.get_doc(doctype, docname)
  670. except frappe.DoesNotExistError:
  671. if ignore_error:
  672. return
  673. raise
  674. @staticmethod
  675. def get_relative_dt(days):
  676. """Get relative to current datetime. Only relative days are supported.
  677. """
  678. return add_days(get_datetime(), days)
  679. @staticmethod
  680. def get_users_linked_to_account(email_account):
  681. """Get list of users who linked to Email account.
  682. """
  683. users = frappe.get_all("User Email", filters={"email_account": email_account.name},
  684. fields=["parent"])
  685. return list(set([user.get("parent") for user in users]))
  686. @staticmethod
  687. def clean_subject(subject):
  688. """Remove Prefixes like 'fw', FWD', 're' etc from subject.
  689. """
  690. # Match strings like "fw:", "re :" etc.
  691. regex = r"(^\s*(fw|fwd|wg)[^:]*:|\s*(re|aw)[^:]*:\s*)*"
  692. return frappe.as_unicode(strip(re.sub(regex, "", subject, 0, flags=re.IGNORECASE)))
  693. @staticmethod
  694. def get_email_fields(doctype):
  695. """Returns Email related fields of a doctype.
  696. """
  697. fields = frappe._dict()
  698. email_fields = ['subject_field', 'sender_field']
  699. meta = frappe.get_meta(doctype)
  700. for field in email_fields:
  701. if hasattr(meta, field):
  702. fields[field] = getattr(meta, field)
  703. return fields
  704. @staticmethod
  705. def get_document(self, doctype, name):
  706. """Is same as frappe.get_doc but suppresses the DoesNotExist error.
  707. """
  708. try:
  709. return frappe.get_doc(doctype, name)
  710. except frappe.DoesNotExistError:
  711. return None
  712. def as_dict(self):
  713. """
  714. """
  715. return {
  716. "subject": self.subject,
  717. "content": self.get_content(),
  718. 'text_content': self.text_content,
  719. "sent_or_received": "Received",
  720. "sender_full_name": self.from_real_name,
  721. "sender": self.from_email,
  722. "recipients": self.mail.get("To"),
  723. "cc": self.mail.get("CC"),
  724. "email_account": self.email_account.name,
  725. "communication_medium": "Email",
  726. "uid": self.uid,
  727. "message_id": self.message_id,
  728. "communication_date": self.date,
  729. "has_attachment": 1 if self.attachments else 0,
  730. "seen": self.seen_status or 0
  731. }
  732. class TimerMixin(object):
  733. def __init__(self, *args, **kwargs):
  734. self.timeout = kwargs.pop('timeout', 0.0)
  735. self.elapsed_time = 0.0
  736. self._super.__init__(self, *args, **kwargs)
  737. if self.timeout:
  738. # set per operation timeout to one-fifth of total pop timeout
  739. self.sock.settimeout(self.timeout / 5.0)
  740. def _getline(self, *args, **kwargs):
  741. start_time = time.time()
  742. ret = self._super._getline(self, *args, **kwargs)
  743. self.elapsed_time += time.time() - start_time
  744. if self.timeout and self.elapsed_time > self.timeout:
  745. raise EmailTimeoutError
  746. return ret
  747. def quit(self, *args, **kwargs):
  748. self.elapsed_time = 0.0
  749. return self._super.quit(self, *args, **kwargs)
  750. class Timed_POP3(TimerMixin, poplib.POP3):
  751. _super = poplib.POP3
  752. class Timed_POP3_SSL(TimerMixin, poplib.POP3_SSL):
  753. _super = poplib.POP3_SSL
  754. class Timed_IMAP4(TimerMixin, imaplib.IMAP4):
  755. _super = imaplib.IMAP4
  756. class Timed_IMAP4_SSL(TimerMixin, imaplib.IMAP4_SSL):
  757. _super = imaplib.IMAP4_SSL