Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 
 
 

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