You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

453 lines
14 KiB

  1. # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  2. # MIT License. See license.txt
  3. from __future__ import unicode_literals
  4. import frappe, re, os
  5. from frappe.utils.pdf import get_pdf
  6. from frappe.email.smtp import get_outgoing_email_account
  7. from frappe.utils import (get_url, scrub_urls, strip, expand_relative_urls, cint,
  8. split_emails, to_markdown, markdown, encode, random_string, parse_addr)
  9. import email.utils
  10. from six import iteritems, text_type, string_types
  11. from email.mime.multipart import MIMEMultipart
  12. from email.header import Header
  13. def get_email(recipients, sender='', msg='', subject='[No Subject]',
  14. text_content = None, footer=None, print_html=None, formatted=None, attachments=None,
  15. content=None, reply_to=None, cc=[], bcc=[], email_account=None, expose_recipients=None,
  16. inline_images=[], header=None):
  17. """ Prepare an email with the following format:
  18. - multipart/mixed
  19. - multipart/alternative
  20. - text/plain
  21. - multipart/related
  22. - text/html
  23. - inline image
  24. - attachment
  25. """
  26. content = content or msg
  27. emailobj = EMail(sender, recipients, subject, reply_to=reply_to, cc=cc, bcc=bcc, email_account=email_account, expose_recipients=expose_recipients)
  28. if not content.strip().startswith("<"):
  29. content = markdown(content)
  30. emailobj.set_html(content, text_content, footer=footer, header=header,
  31. print_html=print_html, formatted=formatted, inline_images=inline_images)
  32. if isinstance(attachments, dict):
  33. attachments = [attachments]
  34. for attach in (attachments or []):
  35. # cannot attach if no filecontent
  36. if attach.get('fcontent') is None: continue
  37. emailobj.add_attachment(**attach)
  38. return emailobj
  39. class EMail:
  40. """
  41. Wrapper on the email module. Email object represents emails to be sent to the client.
  42. Also provides a clean way to add binary `FileData` attachments
  43. Also sets all messages as multipart/alternative for cleaner reading in text-only clients
  44. """
  45. def __init__(self, sender='', recipients=(), subject='', alternative=0, reply_to=None, cc=(), bcc=(), email_account=None, expose_recipients=None):
  46. from email import charset as Charset
  47. Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8')
  48. if isinstance(recipients, string_types):
  49. recipients = recipients.replace(';', ',').replace('\n', '')
  50. recipients = split_emails(recipients)
  51. # remove null
  52. recipients = filter(None, (strip(r) for r in recipients))
  53. self.sender = sender
  54. self.reply_to = reply_to or sender
  55. self.recipients = recipients
  56. self.subject = subject
  57. self.expose_recipients = expose_recipients
  58. self.msg_root = MIMEMultipart('mixed')
  59. self.msg_alternative = MIMEMultipart('alternative')
  60. self.msg_root.attach(self.msg_alternative)
  61. self.cc = cc or []
  62. self.bcc = bcc or []
  63. self.html_set = False
  64. self.email_account = email_account or get_outgoing_email_account(sender=sender)
  65. def set_html(self, message, text_content = None, footer=None, print_html=None,
  66. formatted=None, inline_images=None, header=None):
  67. """Attach message in the html portion of multipart/alternative"""
  68. if not formatted:
  69. formatted = get_formatted_html(self.subject, message, footer, print_html,
  70. email_account=self.email_account, header=header, sender=self.sender)
  71. # this is the first html part of a multi-part message,
  72. # convert to text well
  73. if not self.html_set:
  74. if text_content:
  75. self.set_text(expand_relative_urls(text_content))
  76. else:
  77. self.set_html_as_text(expand_relative_urls(formatted))
  78. self.set_part_html(formatted, inline_images)
  79. self.html_set = True
  80. def set_text(self, message):
  81. """
  82. Attach message in the text portion of multipart/alternative
  83. """
  84. from email.mime.text import MIMEText
  85. part = MIMEText(message, 'plain', 'utf-8')
  86. self.msg_alternative.attach(part)
  87. def set_part_html(self, message, inline_images):
  88. from email.mime.text import MIMEText
  89. has_inline_images = re.search('''embed=['"].*?['"]''', message)
  90. if has_inline_images:
  91. # process inline images
  92. message, _inline_images = replace_filename_with_cid(message)
  93. # prepare parts
  94. msg_related = MIMEMultipart('related')
  95. html_part = MIMEText(message, 'html', 'utf-8')
  96. msg_related.attach(html_part)
  97. for image in _inline_images:
  98. self.add_attachment(image.get('filename'), image.get('filecontent'),
  99. content_id=image.get('content_id'), parent=msg_related, inline=True)
  100. self.msg_alternative.attach(msg_related)
  101. else:
  102. self.msg_alternative.attach(MIMEText(message, 'html', 'utf-8'))
  103. def set_html_as_text(self, html):
  104. """Set plain text from HTML"""
  105. self.set_text(to_markdown(html))
  106. def set_message(self, message, mime_type='text/html', as_attachment=0, filename='attachment.html'):
  107. """Append the message with MIME content to the root node (as attachment)"""
  108. from email.mime.text import MIMEText
  109. maintype, subtype = mime_type.split('/')
  110. part = MIMEText(message, _subtype = subtype)
  111. if as_attachment:
  112. part.add_header('Content-Disposition', 'attachment', filename=filename)
  113. self.msg_root.attach(part)
  114. def attach_file(self, n):
  115. """attach a file from the `FileData` table"""
  116. from frappe.utils.file_manager import get_file
  117. res = get_file(n)
  118. if not res:
  119. return
  120. self.add_attachment(res[0], res[1])
  121. def add_attachment(self, fname, fcontent, content_type=None,
  122. parent=None, content_id=None, inline=False):
  123. """add attachment"""
  124. if not parent:
  125. parent = self.msg_root
  126. add_attachment(fname, fcontent, content_type, parent, content_id, inline)
  127. def add_pdf_attachment(self, name, html, options=None):
  128. self.add_attachment(name, get_pdf(html, options), 'application/octet-stream')
  129. def validate(self):
  130. """validate the Email Addresses"""
  131. from frappe.utils import validate_email_add
  132. if not self.sender:
  133. self.sender = self.email_account.default_sender
  134. validate_email_add(strip(self.sender), True)
  135. self.reply_to = validate_email_add(strip(self.reply_to) or self.sender, True)
  136. self.replace_sender()
  137. self.recipients = [strip(r) for r in self.recipients]
  138. self.cc = [strip(r) for r in self.cc]
  139. self.bcc = [strip(r) for r in self.bcc]
  140. for e in self.recipients + (self.cc or []) + (self.bcc or []):
  141. validate_email_add(e, True)
  142. def replace_sender(self):
  143. if cint(self.email_account.always_use_account_email_id_as_sender):
  144. self.set_header('X-Original-From', self.sender)
  145. sender_name, sender_email = parse_addr(self.sender)
  146. self.sender = email.utils.formataddr((str(Header(sender_name or self.email_account.name, 'utf-8')), self.email_account.email_id))
  147. def set_message_id(self, message_id, is_notification=False):
  148. if message_id:
  149. self.msg_root["Message-Id"] = '<' + message_id + '>'
  150. else:
  151. self.msg_root["Message-Id"] = get_message_id()
  152. self.msg_root["isnotification"] = '<notification>'
  153. if is_notification:
  154. self.msg_root["isnotification"] = '<notification>'
  155. def set_in_reply_to(self, in_reply_to):
  156. """Used to send the Message-Id of a received email back as In-Reply-To"""
  157. self.msg_root["In-Reply-To"] = in_reply_to
  158. def make(self):
  159. """build into msg_root"""
  160. headers = {
  161. "Subject": strip(self.subject),
  162. "From": self.sender,
  163. "To": ', '.join(self.recipients) if self.expose_recipients=="header" else "<!--recipient-->",
  164. "Date": email.utils.formatdate(),
  165. "Reply-To": self.reply_to if self.reply_to else None,
  166. "Bcc": ', '.join(self.bcc) if self.bcc else None,
  167. "CC": ', '.join(self.cc) if self.cc and self.expose_recipients=="header" else None,
  168. 'X-Frappe-Site': get_url(),
  169. }
  170. # reset headers as values may be changed.
  171. for key, val in iteritems(headers):
  172. self.set_header(key, val)
  173. # call hook to enable apps to modify msg_root before sending
  174. for hook in frappe.get_hooks("make_email_body_message"):
  175. frappe.get_attr(hook)(self)
  176. def set_header(self, key, value):
  177. if key in self.msg_root:
  178. del self.msg_root[key]
  179. self.msg_root[key] = value
  180. def as_string(self):
  181. """validate, build message and convert to string"""
  182. self.validate()
  183. self.make()
  184. return self.msg_root.as_string()
  185. def get_formatted_html(subject, message, footer=None, print_html=None,
  186. email_account=None, header=None, unsubscribe_link=None, sender=None):
  187. if not email_account:
  188. email_account = get_outgoing_email_account(False, sender=sender)
  189. rendered_email = frappe.get_template("templates/emails/standard.html").render({
  190. "header": get_header(header),
  191. "content": message,
  192. "signature": get_signature(email_account),
  193. "footer": get_footer(email_account, footer),
  194. "title": subject,
  195. "print_html": print_html,
  196. "subject": subject
  197. })
  198. html = scrub_urls(rendered_email)
  199. if unsubscribe_link:
  200. html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html)
  201. html = inline_style_in_html(html)
  202. return html
  203. @frappe.whitelist()
  204. def get_email_html(template, args, subject, header=None):
  205. import json
  206. args = json.loads(args)
  207. if header and header.startswith('['):
  208. header = json.loads(header)
  209. email = frappe.utils.jinja.get_email_from_template(template, args)
  210. return get_formatted_html(subject, email[0], header=header)
  211. def inline_style_in_html(html):
  212. ''' Convert email.css and html to inline-styled html
  213. '''
  214. from premailer import Premailer
  215. apps = frappe.get_installed_apps()
  216. css_files = []
  217. for app in apps:
  218. path = 'assets/{0}/css/email.css'.format(app)
  219. if os.path.exists(os.path.abspath(path)):
  220. css_files.append(path)
  221. p = Premailer(html=html, external_styles=css_files, strip_important=False)
  222. return p.transform()
  223. def add_attachment(fname, fcontent, content_type=None,
  224. parent=None, content_id=None, inline=False):
  225. """Add attachment to parent which must an email object"""
  226. from email.mime.audio import MIMEAudio
  227. from email.mime.base import MIMEBase
  228. from email.mime.image import MIMEImage
  229. from email.mime.text import MIMEText
  230. import mimetypes
  231. if not content_type:
  232. content_type, encoding = mimetypes.guess_type(fname)
  233. if not parent:
  234. return
  235. if content_type is None:
  236. # No guess could be made, or the file is encoded (compressed), so
  237. # use a generic bag-of-bits type.
  238. content_type = 'application/octet-stream'
  239. maintype, subtype = content_type.split('/', 1)
  240. if maintype == 'text':
  241. # Note: we should handle calculating the charset
  242. if isinstance(fcontent, text_type):
  243. fcontent = fcontent.encode("utf-8")
  244. part = MIMEText(fcontent, _subtype=subtype, _charset="utf-8")
  245. elif maintype == 'image':
  246. part = MIMEImage(fcontent, _subtype=subtype)
  247. elif maintype == 'audio':
  248. part = MIMEAudio(fcontent, _subtype=subtype)
  249. else:
  250. part = MIMEBase(maintype, subtype)
  251. part.set_payload(fcontent)
  252. # Encode the payload using Base64
  253. from email import encoders
  254. encoders.encode_base64(part)
  255. # Set the filename parameter
  256. if fname:
  257. attachment_type = 'inline' if inline else 'attachment'
  258. part.add_header('Content-Disposition', attachment_type, filename=text_type(fname))
  259. if content_id:
  260. part.add_header('Content-ID', '<{0}>'.format(content_id))
  261. parent.attach(part)
  262. def get_message_id():
  263. '''Returns Message ID created from doctype and name'''
  264. return "<{unique}@{site}>".format(
  265. site=frappe.local.site,
  266. unique=email.utils.make_msgid(random_string(10)).split('@')[0].split('<')[1])
  267. def get_signature(email_account):
  268. if email_account and email_account.add_signature and email_account.signature:
  269. return "<br><br>" + email_account.signature
  270. else:
  271. return ""
  272. def get_footer(email_account, footer=None):
  273. """append a footer (signature)"""
  274. footer = footer or ""
  275. args = {}
  276. if email_account and email_account.footer:
  277. args.update({'email_account_footer': email_account.footer})
  278. company_address = frappe.db.get_default("email_footer_address")
  279. if company_address:
  280. args.update({'company_address': company_address})
  281. if not cint(frappe.db.get_default("disable_standard_email_footer")):
  282. args.update({'default_mail_footer': frappe.get_hooks('default_mail_footer')})
  283. footer += frappe.utils.jinja.get_email_from_template('email_footer', args)[0]
  284. return footer
  285. def replace_filename_with_cid(message):
  286. """ Replaces <img embed="assets/frappe/images/filename.jpg" ...> with
  287. <img src="cid:content_id" ...> and return the modified message and
  288. a list of inline_images with {filename, filecontent, content_id}
  289. """
  290. inline_images = []
  291. while True:
  292. matches = re.search('''embed=["'](.*?)["']''', message)
  293. if not matches: break
  294. groups = matches.groups()
  295. # found match
  296. img_path = groups[0]
  297. filename = img_path.rsplit('/')[-1]
  298. filecontent = get_filecontent_from_path(img_path)
  299. if not filecontent:
  300. message = re.sub('''embed=['"]{0}['"]'''.format(img_path), '', message)
  301. continue
  302. content_id = random_string(10)
  303. inline_images.append({
  304. 'filename': filename,
  305. 'filecontent': filecontent,
  306. 'content_id': content_id
  307. })
  308. message = re.sub('''embed=['"]{0}['"]'''.format(img_path),
  309. 'src="cid:{0}"'.format(content_id), message)
  310. return (message, inline_images)
  311. def get_filecontent_from_path(path):
  312. if not path: return
  313. if path.startswith('/'):
  314. path = path[1:]
  315. if path.startswith('assets/'):
  316. # from public folder
  317. full_path = os.path.abspath(path)
  318. elif path.startswith('files/'):
  319. # public file
  320. full_path = frappe.get_site_path('public', path)
  321. elif path.startswith('private/files/'):
  322. # private file
  323. full_path = frappe.get_site_path(path)
  324. else:
  325. full_path = path
  326. if os.path.exists(full_path):
  327. with open(full_path, 'rb') as f:
  328. filecontent = f.read()
  329. return filecontent
  330. else:
  331. return None
  332. def get_header(header=None):
  333. """ Build header from template """
  334. from frappe.utils.jinja import get_email_from_template
  335. if not header: return None
  336. if isinstance(header, string_types):
  337. # header = 'My Title'
  338. header = [header, None]
  339. if len(header) == 1:
  340. # header = ['My Title']
  341. header.append(None)
  342. # header = ['My Title', 'orange']
  343. title, indicator = header
  344. if not title:
  345. title = frappe.get_hooks('app_title')[-1]
  346. email_header, text = get_email_from_template('email_header', {
  347. 'header_title': title,
  348. 'indicator': indicator
  349. })
  350. return email_header