The license.txt file has been replaced with LICENSE for quite a while now. INAL but it didn't seem accurate to say "hey, checkout license.txt although there's no such file". Apart from this, there were inconsistencies in the headers altogether...this change brings consistency.
474 lines
15 KiB
Python
Executable file
474 lines
15 KiB
Python
Executable file
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
|
# License: MIT. See LICENSE
|
|
|
|
import frappe, re, os
|
|
from frappe.utils.pdf import get_pdf
|
|
from frappe.email.doctype.email_account.email_account import EmailAccount
|
|
from frappe.utils import (get_url, scrub_urls, strip, expand_relative_urls, cint,
|
|
split_emails, to_markdown, markdown, random_string, parse_addr)
|
|
import email.utils
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.header import Header
|
|
from email import policy
|
|
|
|
def get_email(recipients, sender='', msg='', subject='[No Subject]',
|
|
text_content = None, footer=None, print_html=None, formatted=None, attachments=None,
|
|
content=None, reply_to=None, cc=[], bcc=[], email_account=None, expose_recipients=None,
|
|
inline_images=[], header=None):
|
|
""" Prepare an email with the following format:
|
|
- multipart/mixed
|
|
- multipart/alternative
|
|
- text/plain
|
|
- multipart/related
|
|
- text/html
|
|
- inline image
|
|
- attachment
|
|
"""
|
|
content = content or msg
|
|
emailobj = EMail(sender, recipients, subject, reply_to=reply_to, cc=cc, bcc=bcc, email_account=email_account, expose_recipients=expose_recipients)
|
|
|
|
if not content.strip().startswith("<"):
|
|
content = markdown(content)
|
|
|
|
emailobj.set_html(content, text_content, footer=footer, header=header,
|
|
print_html=print_html, formatted=formatted, inline_images=inline_images)
|
|
|
|
if isinstance(attachments, dict):
|
|
attachments = [attachments]
|
|
|
|
for attach in (attachments or []):
|
|
# cannot attach if no filecontent
|
|
if attach.get('fcontent') is None: continue
|
|
emailobj.add_attachment(**attach)
|
|
|
|
return emailobj
|
|
|
|
class EMail:
|
|
"""
|
|
Wrapper on the email module. Email object represents emails to be sent to the client.
|
|
Also provides a clean way to add binary `FileData` attachments
|
|
Also sets all messages as multipart/alternative for cleaner reading in text-only clients
|
|
"""
|
|
def __init__(self, sender='', recipients=(), subject='', alternative=0, reply_to=None, cc=(), bcc=(), email_account=None, expose_recipients=None):
|
|
from email import charset as Charset
|
|
Charset.add_charset('utf-8', Charset.QP, Charset.QP, 'utf-8')
|
|
|
|
if isinstance(recipients, str):
|
|
recipients = recipients.replace(';', ',').replace('\n', '')
|
|
recipients = split_emails(recipients)
|
|
|
|
# remove null
|
|
recipients = filter(None, (strip(r) for r in recipients))
|
|
|
|
self.sender = sender
|
|
self.reply_to = reply_to or sender
|
|
self.recipients = recipients
|
|
self.subject = subject
|
|
self.expose_recipients = expose_recipients
|
|
|
|
self.msg_root = MIMEMultipart('mixed', policy=policy.SMTPUTF8)
|
|
self.msg_alternative = MIMEMultipart('alternative', policy=policy.SMTPUTF8)
|
|
self.msg_root.attach(self.msg_alternative)
|
|
self.cc = cc or []
|
|
self.bcc = bcc or []
|
|
self.html_set = False
|
|
|
|
self.email_account = email_account or \
|
|
EmailAccount.find_outgoing(match_by_email=sender, _raise_error=True)
|
|
|
|
def set_html(self, message, text_content = None, footer=None, print_html=None,
|
|
formatted=None, inline_images=None, header=None):
|
|
"""Attach message in the html portion of multipart/alternative"""
|
|
if not formatted:
|
|
formatted = get_formatted_html(self.subject, message, footer, print_html,
|
|
email_account=self.email_account, header=header, sender=self.sender)
|
|
|
|
# this is the first html part of a multi-part message,
|
|
# convert to text well
|
|
if not self.html_set:
|
|
if text_content:
|
|
self.set_text(expand_relative_urls(text_content))
|
|
else:
|
|
self.set_html_as_text(expand_relative_urls(formatted))
|
|
|
|
self.set_part_html(formatted, inline_images)
|
|
self.html_set = True
|
|
|
|
def set_text(self, message):
|
|
"""
|
|
Attach message in the text portion of multipart/alternative
|
|
"""
|
|
from email.mime.text import MIMEText
|
|
part = MIMEText(message, 'plain', 'utf-8', policy=policy.SMTPUTF8)
|
|
self.msg_alternative.attach(part)
|
|
|
|
def set_part_html(self, message, inline_images):
|
|
from email.mime.text import MIMEText
|
|
|
|
has_inline_images = re.search('''embed=['"].*?['"]''', message)
|
|
|
|
if has_inline_images:
|
|
# process inline images
|
|
message, _inline_images = replace_filename_with_cid(message)
|
|
|
|
# prepare parts
|
|
msg_related = MIMEMultipart('related', policy=policy.SMTPUTF8)
|
|
|
|
html_part = MIMEText(message, 'html', 'utf-8', policy=policy.SMTPUTF8)
|
|
msg_related.attach(html_part)
|
|
|
|
for image in _inline_images:
|
|
self.add_attachment(image.get('filename'), image.get('filecontent'),
|
|
content_id=image.get('content_id'), parent=msg_related, inline=True)
|
|
|
|
self.msg_alternative.attach(msg_related)
|
|
else:
|
|
self.msg_alternative.attach(MIMEText(message, 'html', 'utf-8', policy=policy.SMTPUTF8))
|
|
|
|
def set_html_as_text(self, html):
|
|
"""Set plain text from HTML"""
|
|
self.set_text(to_markdown(html))
|
|
|
|
def set_message(self, message, mime_type='text/html', as_attachment=0, filename='attachment.html'):
|
|
"""Append the message with MIME content to the root node (as attachment)"""
|
|
from email.mime.text import MIMEText
|
|
|
|
maintype, subtype = mime_type.split('/')
|
|
part = MIMEText(message, _subtype = subtype, policy=policy.SMTPUTF8)
|
|
|
|
if as_attachment:
|
|
part.add_header('Content-Disposition', 'attachment', filename=filename)
|
|
|
|
self.msg_root.attach(part)
|
|
|
|
def attach_file(self, n):
|
|
"""attach a file from the `FileData` table"""
|
|
_file = frappe.get_doc("File", {"file_name": n})
|
|
content = _file.get_content()
|
|
if not content:
|
|
return
|
|
|
|
self.add_attachment(_file.file_name, content)
|
|
|
|
def add_attachment(self, fname, fcontent, content_type=None,
|
|
parent=None, content_id=None, inline=False):
|
|
"""add attachment"""
|
|
|
|
if not parent:
|
|
parent = self.msg_root
|
|
|
|
add_attachment(fname, fcontent, content_type, parent, content_id, inline)
|
|
|
|
def add_pdf_attachment(self, name, html, options=None):
|
|
self.add_attachment(name, get_pdf(html, options), 'application/octet-stream')
|
|
|
|
def validate(self):
|
|
"""validate the Email Addresses"""
|
|
from frappe.utils import validate_email_address
|
|
|
|
if not self.sender:
|
|
self.sender = self.email_account.default_sender
|
|
|
|
validate_email_address(strip(self.sender), True)
|
|
self.reply_to = validate_email_address(strip(self.reply_to) or self.sender, True)
|
|
|
|
self.replace_sender()
|
|
self.replace_sender_name()
|
|
|
|
self.recipients = [strip(r) for r in self.recipients]
|
|
self.cc = [strip(r) for r in self.cc]
|
|
self.bcc = [strip(r) for r in self.bcc]
|
|
|
|
for e in self.recipients + (self.cc or []) + (self.bcc or []):
|
|
validate_email_address(e, True)
|
|
|
|
def replace_sender(self):
|
|
if cint(self.email_account.always_use_account_email_id_as_sender):
|
|
self.set_header('X-Original-From', self.sender)
|
|
sender_name, sender_email = parse_addr(self.sender)
|
|
self.sender = email.utils.formataddr((str(Header(sender_name or self.email_account.name, 'utf-8')), self.email_account.email_id))
|
|
|
|
def replace_sender_name(self):
|
|
if cint(self.email_account.always_use_account_name_as_sender_name):
|
|
self.set_header('X-Original-From', self.sender)
|
|
sender_name, sender_email = parse_addr(self.sender)
|
|
self.sender = email.utils.formataddr((str(Header(self.email_account.name, 'utf-8')), sender_email))
|
|
|
|
def set_message_id(self, message_id, is_notification=False):
|
|
if message_id:
|
|
message_id = '<' + message_id + '>'
|
|
else:
|
|
message_id = get_message_id()
|
|
self.set_header('isnotification', '<notification>')
|
|
|
|
if is_notification:
|
|
self.set_header('isnotification', '<notification>')
|
|
|
|
self.set_header('Message-Id', message_id)
|
|
|
|
def set_in_reply_to(self, in_reply_to):
|
|
"""Used to send the Message-Id of a received email back as In-Reply-To"""
|
|
self.set_header('In-Reply-To', in_reply_to)
|
|
|
|
def make(self):
|
|
"""build into msg_root"""
|
|
headers = {
|
|
"Subject": strip(self.subject),
|
|
"From": self.sender,
|
|
"To": ', '.join(self.recipients) if self.expose_recipients=="header" else "<!--recipient-->",
|
|
"Date": email.utils.formatdate(),
|
|
"Reply-To": self.reply_to if self.reply_to else None,
|
|
"CC": ', '.join(self.cc) if self.cc and self.expose_recipients=="header" else None,
|
|
'X-Frappe-Site': get_url(),
|
|
}
|
|
|
|
# reset headers as values may be changed.
|
|
for key, val in headers.items():
|
|
if val:
|
|
self.set_header(key, val)
|
|
|
|
# call hook to enable apps to modify msg_root before sending
|
|
for hook in frappe.get_hooks("make_email_body_message"):
|
|
frappe.get_attr(hook)(self)
|
|
|
|
def set_header(self, key, value):
|
|
if key in self.msg_root:
|
|
del self.msg_root[key]
|
|
|
|
try:
|
|
self.msg_root[key] = value
|
|
except ValueError:
|
|
self.msg_root[key] = sanitize_email_header(value)
|
|
|
|
def as_string(self):
|
|
"""validate, build message and convert to string"""
|
|
self.validate()
|
|
self.make()
|
|
return self.msg_root.as_string(policy=policy.SMTPUTF8)
|
|
|
|
def get_formatted_html(subject, message, footer=None, print_html=None,
|
|
email_account=None, header=None, unsubscribe_link=None, sender=None, with_container=False):
|
|
|
|
email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender)
|
|
|
|
signature = None
|
|
if "<!-- signature-included -->" not in message:
|
|
signature = get_signature(email_account)
|
|
|
|
rendered_email = frappe.get_template("templates/emails/standard.html").render({
|
|
"brand_logo": get_brand_logo(email_account) if with_container or header else None,
|
|
"with_container": with_container,
|
|
"site_url": get_url(),
|
|
"header": get_header(header),
|
|
"content": message,
|
|
"signature": signature,
|
|
"footer": get_footer(email_account, footer),
|
|
"title": subject,
|
|
"print_html": print_html,
|
|
"subject": subject
|
|
})
|
|
|
|
html = scrub_urls(rendered_email)
|
|
|
|
if unsubscribe_link:
|
|
html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html)
|
|
|
|
html = inline_style_in_html(html)
|
|
return html
|
|
|
|
@frappe.whitelist()
|
|
def get_email_html(template, args, subject, header=None, with_container=False):
|
|
import json
|
|
with_container = cint(with_container)
|
|
args = json.loads(args)
|
|
if header and header.startswith('['):
|
|
header = json.loads(header)
|
|
email = frappe.utils.jinja.get_email_from_template(template, args)
|
|
return get_formatted_html(subject, email[0], header=header, with_container=with_container)
|
|
|
|
def inline_style_in_html(html):
|
|
''' Convert email.css and html to inline-styled html
|
|
'''
|
|
from premailer import Premailer
|
|
from frappe.utils.jinja_globals import bundled_asset
|
|
|
|
# get email css files from hooks
|
|
css_files = frappe.get_hooks('email_css')
|
|
css_files = [bundled_asset(path) for path in css_files]
|
|
css_files = [path.lstrip('/') for path in css_files]
|
|
css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))]
|
|
|
|
p = Premailer(html=html, external_styles=css_files, strip_important=False)
|
|
|
|
return p.transform()
|
|
|
|
|
|
def add_attachment(fname, fcontent, content_type=None,
|
|
parent=None, content_id=None, inline=False):
|
|
"""Add attachment to parent which must an email object"""
|
|
from email.mime.audio import MIMEAudio
|
|
from email.mime.base import MIMEBase
|
|
from email.mime.image import MIMEImage
|
|
from email.mime.text import MIMEText
|
|
|
|
import mimetypes
|
|
if not content_type:
|
|
content_type, encoding = mimetypes.guess_type(fname)
|
|
|
|
if not parent:
|
|
return
|
|
|
|
if content_type is None:
|
|
# No guess could be made, or the file is encoded (compressed), so
|
|
# use a generic bag-of-bits type.
|
|
content_type = 'application/octet-stream'
|
|
|
|
maintype, subtype = content_type.split('/', 1)
|
|
if maintype == 'text':
|
|
# Note: we should handle calculating the charset
|
|
if isinstance(fcontent, str):
|
|
fcontent = fcontent.encode("utf-8")
|
|
part = MIMEText(fcontent, _subtype=subtype, _charset="utf-8")
|
|
elif maintype == 'image':
|
|
part = MIMEImage(fcontent, _subtype=subtype)
|
|
elif maintype == 'audio':
|
|
part = MIMEAudio(fcontent, _subtype=subtype)
|
|
else:
|
|
part = MIMEBase(maintype, subtype)
|
|
part.set_payload(fcontent)
|
|
# Encode the payload using Base64
|
|
from email import encoders
|
|
encoders.encode_base64(part)
|
|
|
|
# Set the filename parameter
|
|
if fname:
|
|
attachment_type = 'inline' if inline else 'attachment'
|
|
part.add_header('Content-Disposition', attachment_type, filename=str(fname))
|
|
if content_id:
|
|
part.add_header('Content-ID', '<{0}>'.format(content_id))
|
|
|
|
parent.attach(part)
|
|
|
|
def get_message_id():
|
|
'''Returns Message ID created from doctype and name'''
|
|
return email.utils.make_msgid(domain=frappe.local.site)
|
|
|
|
def get_signature(email_account):
|
|
if email_account and email_account.add_signature and email_account.signature:
|
|
return "<br>" + email_account.signature
|
|
else:
|
|
return ""
|
|
|
|
def get_footer(email_account, footer=None):
|
|
"""append a footer (signature)"""
|
|
footer = footer or ""
|
|
|
|
args = {}
|
|
|
|
if email_account and email_account.footer:
|
|
args.update({'email_account_footer': email_account.footer})
|
|
|
|
sender_address = frappe.db.get_default("email_footer_address")
|
|
|
|
if sender_address:
|
|
args.update({'sender_address': sender_address})
|
|
|
|
if not cint(frappe.db.get_default("disable_standard_email_footer")):
|
|
args.update({'default_mail_footer': frappe.get_hooks('default_mail_footer')})
|
|
|
|
footer += frappe.utils.jinja.get_email_from_template('email_footer', args)[0]
|
|
|
|
return footer
|
|
|
|
def replace_filename_with_cid(message):
|
|
""" Replaces <img embed="assets/frappe/images/filename.jpg" ...> with
|
|
<img src="cid:content_id" ...> and return the modified message and
|
|
a list of inline_images with {filename, filecontent, content_id}
|
|
"""
|
|
|
|
inline_images = []
|
|
|
|
while True:
|
|
matches = re.search('''embed=["'](.*?)["']''', message)
|
|
if not matches: break
|
|
groups = matches.groups()
|
|
|
|
# found match
|
|
img_path = groups[0]
|
|
filename = img_path.rsplit('/')[-1]
|
|
|
|
filecontent = get_filecontent_from_path(img_path)
|
|
if not filecontent:
|
|
message = re.sub('''embed=['"]{0}['"]'''.format(img_path), '', message)
|
|
continue
|
|
|
|
content_id = random_string(10)
|
|
|
|
inline_images.append({
|
|
'filename': filename,
|
|
'filecontent': filecontent,
|
|
'content_id': content_id
|
|
})
|
|
|
|
message = re.sub('''embed=['"]{0}['"]'''.format(img_path),
|
|
'src="cid:{0}"'.format(content_id), message)
|
|
|
|
return (message, inline_images)
|
|
|
|
def get_filecontent_from_path(path):
|
|
if not path: return
|
|
|
|
if path.startswith('/'):
|
|
path = path[1:]
|
|
|
|
if path.startswith('assets/'):
|
|
# from public folder
|
|
full_path = os.path.abspath(path)
|
|
elif path.startswith('files/'):
|
|
# public file
|
|
full_path = frappe.get_site_path('public', path)
|
|
elif path.startswith('private/files/'):
|
|
# private file
|
|
full_path = frappe.get_site_path(path)
|
|
else:
|
|
full_path = path
|
|
|
|
if os.path.exists(full_path):
|
|
with open(full_path, 'rb') as f:
|
|
filecontent = f.read()
|
|
|
|
return filecontent
|
|
else:
|
|
return None
|
|
|
|
|
|
def get_header(header=None):
|
|
""" Build header from template """
|
|
from frappe.utils.jinja import get_email_from_template
|
|
|
|
if not header: return None
|
|
|
|
if isinstance(header, str):
|
|
# header = 'My Title'
|
|
header = [header, None]
|
|
if len(header) == 1:
|
|
# header = ['My Title']
|
|
header.append(None)
|
|
# header = ['My Title', 'orange']
|
|
title, indicator = header
|
|
|
|
if not title:
|
|
title = frappe.get_hooks('app_title')[-1]
|
|
|
|
email_header, text = get_email_from_template('email_header', {
|
|
'header_title': title,
|
|
'indicator': indicator
|
|
})
|
|
|
|
return email_header
|
|
|
|
def sanitize_email_header(str):
|
|
return str.replace('\r', '').replace('\n', '')
|
|
|
|
def get_brand_logo(email_account):
|
|
return email_account.get('brand_logo')
|