Browse Source

Email (#3625)

* 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 body
version-14
Faris Ansari 8 years ago
committed by Rushabh Mehta
parent
commit
044ac18cb5
7 changed files with 240 additions and 49 deletions
  1. +10
    -3
      frappe/__init__.py
  2. +44
    -18
      frappe/email/email_body.py
  3. +8
    -6
      frappe/email/queue.py
  4. +103
    -0
      frappe/email/test_email_body.py
  5. +10
    -0
      frappe/templates/emails/email_header.html
  6. +49
    -22
      frappe/templates/emails/standard.html
  7. +16
    -0
      frappe/utils/jinja.py

+ 10
- 3
frappe/__init__.py View File

@@ -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,


+ 44
- 18
frappe/email/email_body.py View File

@@ -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

+ 8
- 6
frappe/email/queue.py View File

@@ -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",


+ 103
- 0
frappe/email/test_email_body.py View File

@@ -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)

+ 10
- 0
frappe/templates/emails/email_header.html View File

@@ -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>

+ 49
- 22
frappe/templates/emails/standard.html View File

@@ -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>

+ 16
- 0
frappe/utils/jinja.py View File

@@ -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


Loading…
Cancel
Save