* inline images working, ability to use template name for emails * Extract header into separate file * Remove erp-logo * minor refactor * [minor] codacy * fix for test * Add test case for email body * remove unused imports * Add more tests for email bodyversion-14
@@ -12,7 +12,7 @@ import os, sys, importlib, inspect, json | |||
# public | |||
from .exceptions import * | |||
from .utils.jinja import get_jenv, get_template, render_template | |||
from .utils.jinja import get_jenv, get_template, render_template, get_email_from_template | |||
__version__ = '8.3.10' | |||
__title__ = "Frappe Framework" | |||
@@ -380,7 +380,7 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message | |||
attachments=None, content=None, doctype=None, name=None, reply_to=None, | |||
cc=[], message_id=None, in_reply_to=None, send_after=None, expose_recipients=None, | |||
send_priority=1, communication=None, retry=1, now=None, read_receipt=None, is_notification=False, | |||
inline_images=None): | |||
inline_images=None, template=None, args=None): | |||
"""Send email using user's default **Email Account** or global default **Email Account**. | |||
@@ -403,7 +403,14 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message | |||
:param expose_recipients: Display all recipients in the footer message - "This email was sent to" | |||
:param communication: Communication link to be set in Email Queue record | |||
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id | |||
:param template: Name of html template from templates/emails folder | |||
:param args: Arguments for rendering the template | |||
""" | |||
text_content = None | |||
if template: | |||
message, text_content = get_email_from_template(template, args) | |||
message = content or message | |||
if as_markdown: | |||
@@ -415,7 +422,7 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message | |||
import email.queue | |||
email.queue.send(recipients=recipients, sender=sender, | |||
subject=subject, message=message, | |||
subject=subject, message=message, text_content=text_content, | |||
reference_doctype = doctype or reference_doctype, reference_name = name or reference_name, | |||
unsubscribe_method=unsubscribe_method, unsubscribe_params=unsubscribe_params, unsubscribe_message=unsubscribe_message, | |||
attachments=attachments, reply_to=reply_to, cc=cc, message_id=message_id, in_reply_to=in_reply_to, | |||
@@ -16,7 +16,15 @@ 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=[], email_account=None, expose_recipients=None, | |||
inline_images=[]): | |||
"""send an html email as multipart with attachments and all""" | |||
""" 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, email_account=email_account, expose_recipients=expose_recipients) | |||
@@ -58,8 +66,8 @@ class EMail: | |||
self.expose_recipients = expose_recipients | |||
self.msg_root = MIMEMultipart('mixed') | |||
self.msg_multipart = MIMEMultipart('alternative') | |||
self.msg_root.attach(self.msg_multipart) | |||
self.msg_alternative = MIMEMultipart('alternative') | |||
self.msg_root.attach(self.msg_alternative) | |||
self.cc = cc or [] | |||
self.html_set = False | |||
@@ -88,33 +96,42 @@ class EMail: | |||
""" | |||
from email.mime.text import MIMEText | |||
part = MIMEText(message, 'plain', 'utf-8') | |||
self.msg_multipart.attach(part) | |||
self.msg_alternative.attach(part) | |||
def set_part_html(self, message, inline_images): | |||
from email.mime.text import MIMEText | |||
if inline_images: | |||
related = MIMEMultipart('related') | |||
# process inline images | |||
_inline_images = [] | |||
for image in inline_images: | |||
# images in dict like {filename:'', filecontent:'raw'} | |||
content_id = random_string(10) | |||
message = replace_filename_with_cid(message, | |||
image.get('filename'), content_id) | |||
# replace filename in message with CID | |||
message = re.sub('''src=['"]{0}['"]'''.format(image.get('filename')), | |||
'src="cid:{0}"'.format(content_id), message) | |||
_inline_images.append({ | |||
'filename': image.get('filename'), | |||
'filecontent': image.get('filecontent'), | |||
'content_id': content_id | |||
}) | |||
self.add_attachment(image.get('filename'), image.get('filecontent'), | |||
None, content_id=content_id, parent=related) | |||
# prepare parts | |||
msg_related = MIMEMultipart('related') | |||
html_part = MIMEText(message, 'html', 'utf-8') | |||
related.attach(html_part) | |||
msg_related.attach(html_part) | |||
self.msg_multipart.attach(related) | |||
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_multipart.attach(MIMEText(message, 'html', 'utf-8')) | |||
self.msg_alternative.attach(MIMEText(message, 'html', 'utf-8')) | |||
def set_html_as_text(self, html): | |||
"""return html2text""" | |||
"""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'): | |||
@@ -139,7 +156,7 @@ class EMail: | |||
self.add_attachment(res[0], res[1]) | |||
def add_attachment(self, fname, fcontent, content_type=None, | |||
parent=None, content_id=None): | |||
parent=None, content_id=None, inline=False): | |||
"""add attachment""" | |||
from email.mime.audio import MIMEAudio | |||
from email.mime.base import MIMEBase | |||
@@ -174,8 +191,8 @@ class EMail: | |||
# Set the filename parameter | |||
if fname: | |||
part.add_header(b'Content-Disposition', | |||
("attachment; filename=\"%s\"" % fname).encode('utf-8')) | |||
attachment_type = 'inline' if inline else 'attachment' | |||
part.add_header(b'Content-Disposition', attachment_type, filename=fname.encode('utf=8')) | |||
if content_id: | |||
part.add_header(b'Content-ID', '<{0}>'.format(content_id)) | |||
@@ -311,3 +328,12 @@ def get_footer(email_account, footer=None): | |||
footer += '<div style="margin: 15px auto;">{0}</div>'.format(default_mail_footer) | |||
return footer | |||
def replace_filename_with_cid(message, filename, content_id): | |||
""" Replaces <img embed="filename.jpg" ...> with | |||
<img src="cid:content_id" ...> | |||
""" | |||
message = re.sub('''embed=['"]{0}['"]'''.format(filename), | |||
'src="cid:{0}"'.format(content_id), message) | |||
return message |
@@ -17,7 +17,7 @@ from frappe.utils.scheduler import log | |||
class EmailLimitCrossedError(frappe.ValidationError): pass | |||
def send(recipients=None, sender=None, subject=None, message=None, reference_doctype=None, | |||
def send(recipients=None, sender=None, subject=None, message=None, text_content=None, reference_doctype=None, | |||
reference_name=None, unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, | |||
attachments=None, reply_to=None, cc=[], message_id=None, in_reply_to=None, send_after=None, | |||
expose_recipients=None, send_priority=1, communication=None, now=False, read_receipt=None, | |||
@@ -28,6 +28,7 @@ def send(recipients=None, sender=None, subject=None, message=None, reference_doc | |||
:param sender: Email sender. | |||
:param subject: Email subject. | |||
:param message: Email message. | |||
:param text_content: Text version of email message. | |||
:param reference_doctype: Reference DocType of caller document. | |||
:param reference_name: Reference name of caller document. | |||
:param send_priority: Priority for Email Queue, default 1. | |||
@@ -65,12 +66,13 @@ def send(recipients=None, sender=None, subject=None, message=None, reference_doc | |||
check_email_limit(recipients) | |||
formatted = get_formatted_html(subject, message, email_account=email_account) | |||
if not text_content: | |||
try: | |||
text_content = html2text(message) | |||
except HTMLParser.HTMLParseError: | |||
text_content = "See html attachment" | |||
try: | |||
text_content = html2text(formatted) | |||
except HTMLParser.HTMLParseError: | |||
text_content = "See html attachment" | |||
formatted = get_formatted_html(subject, message, email_account=email_account) | |||
if reference_doctype and reference_name: | |||
unsubscribed = [d.email for d in frappe.db.get_all("Email Unsubscribe", "email", | |||
@@ -0,0 +1,103 @@ | |||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors | |||
# License: GNU General Public License v3. See license.txt | |||
from __future__ import unicode_literals | |||
import unittest, os, base64 | |||
from frappe.email.email_body import replace_filename_with_cid, get_email | |||
class TestEmailBody(unittest.TestCase): | |||
def setUp(self): | |||
email_html = ''' | |||
<div> | |||
<h3>Hey John Doe!</h3> | |||
<p>This is embedded image you asked for</p> | |||
<img embed="favicon.png" /> | |||
</div> | |||
''' | |||
email_text = ''' | |||
Hey John Doe! | |||
This is the text version of this email | |||
''' | |||
frappe_app_path = os.path.join('..', 'apps', 'frappe') | |||
img_path = os.path.join(frappe_app_path, 'frappe', 'public', 'images', 'favicon.png') | |||
with open(img_path) as f: | |||
img_content = f.read() | |||
img_base64 = base64.b64encode(img_content) | |||
# email body keeps 76 characters on one line | |||
self.img_base64 = fixed_column_width(img_base64, 76) | |||
self.email_string = get_email( | |||
recipients=['test@example.com'], | |||
sender='me@example.com', | |||
subject='Test Subject', | |||
content=email_html, | |||
text_content=email_text, | |||
inline_images=[{ | |||
'filename': 'favicon.png', | |||
'filecontent': img_content | |||
}] | |||
).as_string() | |||
def test_image(self): | |||
img_signature = ''' | |||
Content-Type: image/png | |||
MIME-Version: 1.0 | |||
Content-Transfer-Encoding: base64 | |||
Content-Disposition: inline; filename="favicon.png" | |||
''' | |||
self.assertTrue(img_signature in self.email_string) | |||
self.assertTrue(self.img_base64 in self.email_string) | |||
def test_text_content(self): | |||
text_content = ''' | |||
Content-Type: text/plain; charset="utf-8" | |||
MIME-Version: 1.0 | |||
Content-Transfer-Encoding: quoted-printable | |||
Hey John Doe! | |||
This is the text version of this email | |||
''' | |||
self.assertTrue(text_content in self.email_string) | |||
def test_email_content(self): | |||
html_head = ''' | |||
Content-Type: text/html; charset="utf-8" | |||
MIME-Version: 1.0 | |||
Content-Transfer-Encoding: quoted-printable | |||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.= | |||
w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | |||
<html xmlns=3D"http://www.w3.org/1999/xhtml"> | |||
''' | |||
html = '''<h3>Hey John Doe!</h3>''' | |||
self.assertTrue(html_head in self.email_string) | |||
self.assertTrue(html in self.email_string) | |||
def test_replace_filename_with_cid(self): | |||
original_message = ''' | |||
<div> | |||
<img embed="test.jpg" alt="test" /> | |||
</div> | |||
''' | |||
processed_message = ''' | |||
<div> | |||
<img src="cid:abcdefghij" alt="test" /> | |||
</div> | |||
''' | |||
message = replace_filename_with_cid(original_message, 'test.jpg', 'abcdefghij') | |||
self.assertEquals(message, processed_message) | |||
def fixed_column_width(string, chunk_size): | |||
parts = [string[0+i:chunk_size+i] for i in range(0, len(string), chunk_size)] | |||
return '\n'.join(parts) |
@@ -0,0 +1,10 @@ | |||
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="email_header"> | |||
<tr> | |||
<td width="35" height="50" style="border-bottom: 1px solid #d1d8dd;"> | |||
<img embed="{{brand_image}}" width="24" height="24" style="display: block;" alt="{{brand_text}}"> | |||
</td> | |||
<td style="border-bottom: 1px solid #d1d8dd;"> | |||
<p>{{ brand_text }}</p> | |||
</td> | |||
</tr> | |||
</table> |
@@ -1,28 +1,55 @@ | |||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | |||
<html xmlns="http://www.w3.org/1999/xhtml"> | |||
<head> | |||
<meta name="viewport" content="width=device-width" /> | |||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> | |||
<title>{{ subject or "" }}</title> | |||
<meta name="viewport" content="width=device-width" /> | |||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> | |||
<title>{{ subject or "" }}</title> | |||
</head> | |||
<body style="line-height: 1.5; color: #36414C;"> | |||
<!-- body --> | |||
<div style="font-family: -apple-system, BlinkMacSystemFont, | |||
"Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", | |||
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; | |||
font-size: 14px; padding: 10px;"> | |||
{{ content }} | |||
{{ signature }} | |||
</div> | |||
<!-- footer --> | |||
<div style="margin-top: 30px; font-family: Helvetica, Arial, sans-serif; font-size: 11px; | |||
margin-bottom: 15px; border-top: 1px solid #d1d8dd;" | |||
data-email-footer="true"> | |||
{{ footer }} | |||
</div> | |||
<!-- /footer --> | |||
<div class="print-html">{{ print_html or "" }}</div> | |||
<body style="line-height: 1.5; color: #36414C;"> | |||
<table border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="body_table" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; font-size: 14px;"> | |||
<tr> | |||
<td align="center" valign="top"> | |||
<table border="0" cellpadding="0" cellspacing="0" width="600" id="email_container"> | |||
<tr> | |||
<td valign="top"> | |||
{{ header or "" }} | |||
</td> | |||
</tr> | |||
<tr> | |||
<td valign="top"> | |||
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="email_body"> | |||
<tr> | |||
<td valign="top"> | |||
<p>{{ content }}</p> | |||
<p>{{ signature }}</p> | |||
</td> | |||
</tr> | |||
</table> | |||
</td> | |||
</tr> | |||
<tr> | |||
<td valign="top"> | |||
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="email_footer"> | |||
<tr> | |||
<td valign="top" style="font-family: Helvetica, Arial, sans-serif; font-size: 11px; | |||
margin-bottom: 15px; border-top: 1px solid #d1d8dd;" data-email-footer="true"> | |||
{{ footer }} | |||
</td> | |||
</tr> | |||
<tr> | |||
<td valign="top"> | |||
<div class="print-html">{{ print_html or "" }}</div> | |||
</td> | |||
</tr> | |||
</table> | |||
</td> | |||
</tr> | |||
</table> | |||
</td> | |||
</tr> | |||
</table> | |||
</body> | |||
</html> | |||
</html> |
@@ -22,6 +22,22 @@ def get_jenv(): | |||
def get_template(path): | |||
return get_jenv().get_template(path) | |||
def get_email_from_template(name, args): | |||
from jinja2 import TemplateNotFound | |||
args = args or {} | |||
try: | |||
message = get_template('templates/emails/' + name + '.html').render(args) | |||
except TemplateNotFound: | |||
message = None | |||
try: | |||
text_content = get_template('templates/emails/' + name + '.txt').render(args) | |||
except TemplateNotFound: | |||
text_content = None | |||
return (message, text_content) | |||
def validate_template(html): | |||
"""Throws exception if there is a syntax error in the Jinja Template""" | |||
import frappe | |||