Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 
 
 

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